diff --git a/dependencies.yaml b/dependencies.yaml index b57f0c06d..bf7dbd2c3 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -30,6 +30,11 @@ libfsm: git: https://github.com/EVerest/libfsm.git git_tag: v0.2.0 +# LEM DCBM 400/600 module +libcurl: + git: https://github.com/curl/curl.git + git_tag: curl-8_3_0 + # OCPP libocpp: git: https://github.com/EVerest/libocpp.git diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index d9ac1dcc5..85d668470 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -11,6 +11,7 @@ ev_add_module(GenericPowermeter) ev_add_module(JsForecastDotSolar) ev_add_module(JsPN532TokenProvider) ev_add_module(JsTibber) +ev_add_module(LemDCBM400600) ev_add_module(OCPP) ev_add_module(OCPP201) ev_add_module(PacketSniffer) diff --git a/modules/LemDCBM400600/CMakeLists.txt b/modules/LemDCBM400600/CMakeLists.txt new file mode 100644 index 000000000..ebe67e797 --- /dev/null +++ b/modules/LemDCBM400600/CMakeLists.txt @@ -0,0 +1,36 @@ +# +# AUTO GENERATED - MARKED REGIONS WILL BE KEPT +# template version 3 +# + +# module setup: +# - ${MODULE_NAME}: module name +add_compile_options(-Wpedantic) + +ev_setup_cpp_module() + +# ev@bcc62523-e22b-41d7-ba2f-825b493a3c97:v1 +# insert your custom targets and additional config variables here +option(LEMDCBM_BUILD_TESTS "Build unit tests for LEM DCBM module" OFF) + +target_link_libraries(${MODULE_NAME} PRIVATE CURL::libcurl) + +target_sources(${MODULE_NAME} + PRIVATE + main/lem_dcbm_400600_controller.cpp + main/lem_dcbm_time_sync_helper.cpp + main/http_client.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(LEMDCBM_BUILD_TESTS) + add_subdirectory(tests) +endif() +# ev@c55432ab-152c-45a9-9d2e-7281d50c69c3:v1 diff --git a/modules/LemDCBM400600/LemDCBM400600.cpp b/modules/LemDCBM400600/LemDCBM400600.cpp new file mode 100644 index 000000000..391be706c --- /dev/null +++ b/modules/LemDCBM400600/LemDCBM400600.cpp @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest +#include "LemDCBM400600.hpp" + +namespace module { + +void LemDCBM400600::init() { + invoke_init(*p_main); +} + +void LemDCBM400600::ready() { + invoke_ready(*p_main); +} + +} // namespace module diff --git a/modules/LemDCBM400600/LemDCBM400600.hpp b/modules/LemDCBM400600/LemDCBM400600.hpp new file mode 100644 index 000000000..3faf10bb4 --- /dev/null +++ b/modules/LemDCBM400600/LemDCBM400600.hpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest +#ifndef LEM_DCBM400600_HPP +#define LEM_DCBM400600_HPP + +// +// AUTO GENERATED - MARKED REGIONS WILL BE KEPT +// template version 2 +// + +#include "ld-ev.hpp" + +// headers for provided interface implementations +#include + +// 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; + std::string meter_tls_certificate; + std::string ntp_server_1_ip_addr; + int ntp_server_1_port; + std::string ntp_server_2_ip_addr; + int ntp_server_2_port; + int resilience_initial_connection_retries; + int resilience_initial_connection_retry_delay; + int resilience_transaction_request_retries; + int resilience_transaction_request_retry_delay; +}; + +class LemDCBM400600 : public Everest::ModuleBase { +public: + LemDCBM400600() = delete; + LemDCBM400600(const ModuleInfo& info, std::unique_ptr p_main, Conf& config) : + ModuleBase(info), p_main(std::move(p_main)), config(config){}; + + const std::unique_ptr 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 // LEM_DCBM400600_HPP diff --git a/modules/LemDCBM400600/doc.rst b/modules/LemDCBM400600/doc.rst new file mode 100644 index 000000000..a4e3d28fc --- /dev/null +++ b/modules/LemDCBM400600/doc.rst @@ -0,0 +1,182 @@ +.. _everest_modules_handwritten_LemDCBM400600: + +.. This file is a placeholder for an optional single file handwritten documentation for + the LemDCBM400600 module. + Please decide weather you want to use this single file, + or a set of files in the doc/ directory. + In the latter case, you can delete this file. + In the former case, you can delete the doc/ directory. + +.. This handwritten documentation is optional. In case + you do not want to write it, you can delete this file + and the doc/ directory. + +.. The documentation can be written in reStructuredText, + and will be converted to HTML and PDF by Sphinx. + +******************************************* +LEM DCBM 400/600 +******************************************* + +:ref:`Link ` to the module's reference. +Module implementing the LEM DCBM 400/600 power meter driver adapter via HTTP/HTTPS. + + +Description +=========== + +The module consists of a single ``main`` implementation that serves the ``powermeter`` interface. Requests/commands +to the meter are translated and forwarded to the device via HTTP/HTTPS. + + +Initialization +------------ + +On module initialization, the driver fetches the device's metric id from the ``/v1/status`` api. Consequently, this also ensures +connectivity to the device. +The initialization will fail (with a thrown exception) in case this cannot be established (possibly after a limited amount of retries). + +Furthermore, at initialization the initial time sync setup is scheduled after a 2 minute waiting time (which is then executed +during the module's "ready" thread loop), cf. also the notes on time synchronization below. + +Variable Powermeter +------------ + +Publication of the ``powermeter`` var is done with approx. frequency 1/second. This fetches the current ``livemeasure`` +values from the device's ``/v1/livemeasure`` endpoint and injects the meter id as determined at initialization. + +Command start_transaction +------------ + +A ``start_transaction`` command is directly forwarded via a ``POST`` to the ``/v1/legal`` endpoint with a copy of the transaction request +as payload (up to renaming of attributes). It returns ``true``, if the device (possibly after a limited amount of retries) returns a success +response with a valid payload that indicates a ``running`` transaction status, otherwised it returns ``false``. + + +Command stop_transaction +------------ + +A ``stop_transaction`` command results into two requets to the devie. + +First, a ``PUT`` to the ``/v1/legal`` endpoint stops the transaction. + +Then, a call to the ``/v1/ocmf/`` endpoint fetches the OCMF report for the provided transaction id. Note that this always +fetches the report of the `last` transaction with this id (in case if multiple transactions with the same id had been +running). + +If both requests are successful (possibly after a limited amount of retries), the returned OCMF string is forward 1:1. + +In case of an error, an empty string is returned. + + +Module Configuration +=========== + +The module has the following configuration parameters: + +ip_address +------------ +IP address (or DNS/Host name) of the device. + +port (optional) +------------ +Port used to reach the device. Defaults to ``80``. Note that the default value of ``80`` is used independent on whether +TLS is enabled or not (which is in coherence with the device`s behavior). + +meter_tls_certificate (optional) +------------ +The meter's TLS X.509 certificate in PEM format. If provided, TLS will be used for communication with the device. See +:ref:`notes on TLS ` below. + + +NTP Settings (optional) +------------ + +If NTP servers are supposed to be used for time sync by the device, +those can provided via +- ``ntp_server_1_ip_addr``, ``ntp_server_1_port`` for the first NTP server, and +- ``ntp_server_2_ip_addr``, ``ntp_server_2_port`` for the first NTP server. + +If the first server is provided, NTP will be activated on module initialization. Otherwise, a +regular time sync with the system time will be executed. + +Note that the wording "ip_address" follows the operational manual (cf. 4.2.3. of the `Communication protocols manual`, see references below). +However, according to this manual DNS names are allowed, too. + + + + +Resilience Settings (optional) +------------ +The following optional settings may be set to adapt the resilience behavior behavior of the module: + +- ``resilience_initial_connection_retries`` and ``resilience_initial_connection_retry_delay`` define the number of attempted +retries and delay inbetween in milliseconds in case of an error (failed connection or unexpected response from the device) during the module +initialization. This potentially delays module initialization, but may prevent a module failure at startup (e.g., if the device +is not ready yet). +- ``resilience_transaction_request_retries`` and ``resilience_transaction_request_retry_delay`` similarly +define the according values but for connection attempts during a transaction start or stop command handling. +In order to prevent a greater command return delay (and since the device is assumed to be set up and running when +transactions are started), default values are considerably lower than the ones for initialization. + + + +Notes +=========== + +Time Sync +------------ + +The powermeter device needs to be regularly time synced in order to function properly +(cf. +The module is capable of performing regular syncs with the system time, or -- alternatively -- +allows to setup NTP servers (cf. the configuration parameters above). + +If no NTP server is provided, a sync right before each transaction start is ensured in order to +allow for the maximum possible transaction duration of 48 hours. Cf. the `Operation Manual` section 7.8.1 for +more details. + +Also note the device's manual suggests a start-up time of 2 minutes before settings (such as +time sync) should be persisted (cf. the `Communication protocols manual` section 4). +This is payed regard to in the module. + +Error Handling / Resilience +------------ + +In general responses are checked for a valid response code and body. In case of validation errors or an http error, +requests are retried to provide some resilience. + +For the initialization requests, 25 retry attempts are made with a 10 second delay. +For start/stop transaction requests, 3 retry attempts with a 200ms delay are made. + + +.. _TLS Notes: + +TLS Notes & Limitations +------------ + +The device brings its own self-signed certificate. Since there is no manufacturer root CA, this certificate must be provided +in order to establish a reasonable TLS connection. Note that the provided certificate uses a private key of 1024bit length, which +in general is considered vulnerable. + +.. code-block:: bash + + curl 'http://:/v1/certificate' + +TLS can be enabled via: + +.. code-block:: bash + + curl --location --request PUT 'https://:/v1/settings' \ + --header 'Content-Type: application/json' \ + --data '{ + "http": { + "tls_on": true + } + }' + +References / Links +============ +- `Official product page https://www.lem.com/en/dcbm-400-600 `_ +- `Operation Manual `_ +- `Communication protocols manual `_ diff --git a/modules/LemDCBM400600/main/http_client.cpp b/modules/LemDCBM400600/main/http_client.cpp new file mode 100644 index 000000000..a5890f1e1 --- /dev/null +++ b/modules/LemDCBM400600/main/http_client.cpp @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#include "http_client.hpp" +#include +#include + +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); + 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."); + } +} + +static void setup_libcurl_tls_options_for_connection(CURL* connection, struct curl_blob* dcbm_cert) { + // Since the LEM DCBM uses a certificate of only 1024bit, we need to lower the security level + curl_easy_setopt(connection, CURLOPT_SSL_CIPHER_LIST, "DEFAULT:@SECLEVEL=1"); + // However, we still want to enforce TLS 1.2 or higher + if (curl_easy_setopt(connection, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2 | CURL_SSLVERSION_TLSv1_3) != + CURLE_OK) { + throw std::runtime_error("Failed to set CURLOPT_SSLVERSION. Is libcurl built with TLS support?"); + } + + // We do not want to verify the hostname + if (curl_easy_setopt(connection, CURLOPT_SSL_VERIFYHOST, 0) != CURLE_OK) { + throw std::runtime_error("Failed to set CURLOPT_SSL_VERIFYHOST. Is libcurl built with TLS support?"); + } + // We do want to verify the peer's certificate + if (curl_easy_setopt(connection, CURLOPT_SSL_VERIFYPEER, 1) != CURLE_OK) { + throw std::runtime_error("Failed to set CURLOPT_SSL_VERIFYPEER. Is libcurl built with TLS support?"); + } + // We do not want to use OCSP + // Whether this option is supported or not depends on the SSL backend, so we don't check the error code here. + curl_easy_setopt(connection, CURLOPT_SSL_VERIFYSTATUS, 0); + + // Now pass the DCBM certificate to libcurl + if (curl_easy_setopt(connection, CURLOPT_CAINFO_BLOB, dcbm_cert) != CURLE_OK) { + throw std::runtime_error("Failed to set CURLOPT_CAINFO_BLOB, possibly due to running out of memory."); + } +} + +// 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); + + // Set up TLS options if TLS is enabled + // we define dcbm_cert outside the "if" statement to ensure it outlives curl_easy_perform(). + struct curl_blob dcbm_cert { + (void*)this->dcbm_tls_certificate.c_str(), this->dcbm_tls_certificate.size(), + CURL_BLOB_NOCOPY // curl does not need to copy the cert, since it's not in a temporary location + }; + if (this->tls_enabled) { + setup_libcurl_tls_options_for_connection(connection, &dcbm_cert); + } + + // 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 = this->tls_enabled ? "https" : "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::put(const std::string& path, const std::string& body) const { + CURL* connection = this->create_curl_handle_and_setup_url(path); + + curl_easy_setopt(connection, CURLOPT_UPLOAD, 1); + + // 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, "PUT", 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 diff --git a/modules/LemDCBM400600/main/http_client.hpp b/modules/LemDCBM400600/main/http_client.hpp new file mode 100644 index 000000000..20f1e59c9 --- /dev/null +++ b/modules/LemDCBM400600/main/http_client.hpp @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#ifndef EVEREST_PODPOINT_HTTPCLIENT_H +#define EVEREST_PODPOINT_HTTPCLIENT_H + +#include "fmt/format.h" +#include "http_client_interface.hpp" +#include +#include +#include +#include +#include + +namespace module::main { + +// The DCBM does not print its certificate correctly in its /certificate API. +// In particular, the newlines after -----BEGIN CERTIFICATE----- and before -----END CERTIFICATE----- are missing there. +// This function will add these newlines if they are missing. +static void fixup_tls_certificate(std::string& tls_certificate) { + tls_certificate = std::regex_replace(tls_certificate, std::regex("-----BEGIN CERTIFICATE-----\\s*([^\n])"), + "-----BEGIN CERTIFICATE-----\n$1"); + tls_certificate = std::regex_replace(tls_certificate, std::regex("([^\n])\\s*-----END CERTIFICATE-----"), + "$1\n-----END CERTIFICATE-----"); +} + +class HttpClient : public HttpClientInterface { + +public: + HttpClient() = delete; + + HttpClient(const std::string& host_arg, int port_arg, const std::string& tls_certificate) { + // 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; + dcbm_tls_certificate = tls_certificate; + tls_enabled = !dcbm_tls_certificate.empty(); + fixup_tls_certificate(dcbm_tls_certificate); + } + ~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 put(const std::string& path, const std::string& body) const override; + [[nodiscard]] HttpResponse post(const std::string& path, const std::string& body) const override; + +private: + std::string host; + int port; + bool tls_enabled; + std::string dcbm_tls_certificate; + + [[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_PODPOINT_HTTPCLIENT_H diff --git a/modules/LemDCBM400600/main/http_client_interface.hpp b/modules/LemDCBM400600/main/http_client_interface.hpp new file mode 100644 index 000000000..38b61755c --- /dev/null +++ b/modules/LemDCBM400600/main/http_client_interface.hpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#ifndef EVEREST_PODPOINT_HTTP_CLIENT_INTERFACE_H +#define EVEREST_PODPOINT_HTTP_CLIENT_INTERFACE_H + +#include + +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 put(const std::string& path, const std::string& body) const = 0; + [[nodiscard]] virtual HttpResponse post(const std::string& path, const std::string& body) const = 0; +}; + +} // namespace module::main + +#endif // EVEREST_PODPOINT_HTTP_CLIENT_INTERFACE_H diff --git a/modules/LemDCBM400600/main/lem_dcbm_400600_controller.cpp b/modules/LemDCBM400600/main/lem_dcbm_400600_controller.cpp new file mode 100644 index 000000000..a90b96baf --- /dev/null +++ b/modules/LemDCBM400600/main/lem_dcbm_400600_controller.cpp @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#include "lem_dcbm_400600_controller.hpp" +#include +namespace module::main { + +void LemDCBM400600Controller::init() { + try { + call_with_retry([this]() { this->fetch_meter_id_from_device(); }, this->config.init_number_of_http_retries, + this->config.init_retry_wait_in_milliseconds); + } catch (HttpClientError& http_client_error) { + EVLOG_error << "Initialization of LemDCBM400600Controller failed with http client error: " + << http_client_error.what(); + throw; + } catch (DCBMUnexpectedResponseException& dcbm_error) { + EVLOG_error << "Initialization of LemDCBM400600Controller failed due an unexpected device response: " + << dcbm_error.what(); + throw; + } + + this->time_sync_helper->restart_unsafe_period(); +} + +void LemDCBM400600Controller::fetch_meter_id_from_device() { + auto status_response = this->http_client->get("/v1/status"); + + if (status_response.status_code != 200) { + throw UnexpectedDCBMResponseCode("/v1/status", 200, status_response); + } + try { + this->meter_id = json::parse(status_response.body).at("meterId"); + } catch (json::exception& json_error) { + throw UnexpectedDCBMResponseBody( + "/v1/status", fmt::format("Json error {} for body {}", json_error.what(), status_response.body)); + } +} + +types::powermeter::TransactionStartResponse +LemDCBM400600Controller::start_transaction(const types::powermeter::TransactionReq& value) { + try { + call_with_retry([this, value]() { this->request_device_to_start_transaction(value); }, + this->config.transaction_number_of_http_retries, + this->config.transaction_retry_wait_in_milliseconds); + } catch (DCBMUnexpectedResponseException& error) { + const std::string error_message = + fmt::format("Failed to start transaction {}: {}", value.transaction_id, error.what()); + EVLOG_error << error_message; + return {types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, error_message}; + } catch (HttpClientError& error) { + const std::string error_message = fmt::format( + "Failed to start transaction {} - connection to device failed: {}", value.transaction_id, error.what()); + EVLOG_error << error_message; + return {types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, error_message}; + } + + auto [transaction_min_stop_time, transaction_max_stop_time] = get_transaction_stop_time_bounds(); + + return {types::powermeter::TransactionRequestStatus::OK, {}, transaction_min_stop_time, transaction_max_stop_time}; +} + +void LemDCBM400600Controller::request_device_to_start_transaction(const types::powermeter::TransactionReq& value) { + this->time_sync_helper->sync(*this->http_client); + + auto response = this->http_client->post( + "/v1/legal", module::main::LemDCBM400600Controller::transaction_start_request_to_dcbm_payload(value)); + if (response.status_code != 201) { + throw UnexpectedDCBMResponseCode("/v1/legal", 201, response); + } + try { + bool running = json::parse(response.body).at("running"); + if (!running) { + throw UnexpectedDCBMResponseBody( + "/v1/legal", fmt::format("Created transaction {} has state running = false.", value.transaction_id)); + } + } catch (json::exception& json_error) { + throw UnexpectedDCBMResponseBody("/v1/legal", + fmt::format("Json error {} for body '{}'", json_error.what(), response.body)); + } +} + +types::powermeter::TransactionStopResponse +LemDCBM400600Controller::stop_transaction(const std::string& transaction_id) { + try { + return call_with_retry( + [this, transaction_id]() { + this->request_device_to_stop_transaction(transaction_id); + return types::powermeter::TransactionStopResponse{types::powermeter::TransactionRequestStatus::OK, + fetch_ocmf_result(transaction_id)}; + }, + this->config.transaction_number_of_http_retries, this->config.transaction_retry_wait_in_milliseconds); + } catch (DCBMUnexpectedResponseException& error) { + std::string error_message = fmt::format("Failed to stop transaction {}: {}", transaction_id, error.what()); + EVLOG_error << error_message; + return types::powermeter::TransactionStopResponse{ + types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, {}, error_message}; + } catch (HttpClientError& error) { + std::string error_message = fmt::format("Failed to stop transaction {} - connection to device failed: {}", + transaction_id, error.what()); + EVLOG_error << error_message; + return types::powermeter::TransactionStopResponse{ + types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, {}, error_message}; + } +} + +void LemDCBM400600Controller::request_device_to_stop_transaction(const std::string& transaction_id) { + std::string endpoint = fmt::format("/v1/legal?transactionId={}", transaction_id); + auto legal_api_response = this->http_client->put(endpoint, R"({"running": false})"); + + if (legal_api_response.status_code != 200) { + throw UnexpectedDCBMResponseCode(endpoint, 200, legal_api_response); + } + + try { + int status = json::parse(legal_api_response.body).at("meterValue").at("transactionStatus"); + bool transaction_is_ongoing = (status & 0b100) != 0; // third status bit "transactionIsOnGoing" must be false + if (transaction_is_ongoing) { + throw UnexpectedDCBMResponseBody( + endpoint, fmt::format("Transaction stop request for transaction {} returned device status {}.", + transaction_id, status)); + } + } catch (json::exception& json_error) { + throw UnexpectedDCBMResponseBody( + endpoint, fmt::format("Json error '{}' for body {}", json_error.what(), legal_api_response.body)); + } +} + +std::string LemDCBM400600Controller::fetch_ocmf_result(const std::string& transaction_id) { + const std::string ocmf_endpoint = fmt::format("/v1/ocmf?transactionId={}", transaction_id); + auto ocmf_api_response = this->http_client->get(ocmf_endpoint); + + if (ocmf_api_response.status_code != 200) { + throw UnexpectedDCBMResponseCode(ocmf_endpoint, 200, ocmf_api_response); + } + + if (ocmf_api_response.body.empty()) { + throw UnexpectedDCBMResponseBody(ocmf_endpoint, "Returned empty body"); + } + + return ocmf_api_response.body; +} + +types::powermeter::Powermeter LemDCBM400600Controller::get_powermeter() { + this->time_sync_helper->sync_if_deadline_expired(*this->http_client); + + auto response = this->http_client->get("/v1/livemeasure"); + if (response.status_code != 200) { + throw UnexpectedDCBMResponseCode("/v1/livemeasure", 200, response); + } + types::powermeter::Powermeter value; + try { + this->convert_livemeasure_to_powermeter(response.body, value); + return value; + } catch (json::exception& json_error) { + throw UnexpectedDCBMResponseBody("/v1/livemeasure", fmt::format("Json error '{}'", json_error.what())); + } +} + +void LemDCBM400600Controller::convert_livemeasure_to_powermeter(const std::string& livemeasure, + types::powermeter::Powermeter& powermeter) { + json data = json::parse(livemeasure); + powermeter.timestamp = data.at("timestamp"); + powermeter.meter_id.emplace(this->meter_id); + powermeter.energy_Wh_import = {data.at("energyImportTotal")}; + powermeter.energy_Wh_export.emplace(types::units::Energy{data.at("energyExportTotal")}); + auto voltage = types::units::Voltage{}; + voltage.DC = data.at("voltage"); + powermeter.voltage_V.emplace(voltage); + auto current = types::units::Current{}; + current.DC = data.at("current"); + powermeter.current_A.emplace(current); + powermeter.power_W.emplace(types::units::Power{data.at("power")}); +} +std::string +LemDCBM400600Controller::transaction_start_request_to_dcbm_payload(const types::powermeter::TransactionReq& request) { + return nlohmann::ordered_json{{"evseId", request.evse_id}, {"transactionId", request.transaction_id}, + {"clientId", request.client_id}, {"tariffId", request.tariff_id}, + {"cableId", request.cable_id}, {"userData", request.user_data}} + .dump(); +} + +std::pair LemDCBM400600Controller::get_transaction_stop_time_bounds() { + // The LEM DCBM 400/600 Operations manual (7.2.2.) states + // "Minimum duration for transactions is 2 minutes, to prevent potential memory storage weaknesses." + // Further, the communication protocol states (4.2.9.): + // "If after a period of 48h the time was not set, time synchronization expires (preventing new transactions and + // invalidating on-going one)."" Since during an ongoing transaction, now time can synced, the max duration is set + // to 48 hours (minus a small delta). + auto now = std::chrono::time_point::clock::now(); + return { + Everest::Date::to_rfc3339(now + std::chrono::minutes(2)), + Everest::Date::to_rfc3339(now + std::chrono::hours(48) - std::chrono::minutes(1)), + }; +} + +} // namespace module::main diff --git a/modules/LemDCBM400600/main/lem_dcbm_400600_controller.hpp b/modules/LemDCBM400600/main/lem_dcbm_400600_controller.hpp new file mode 100644 index 000000000..97c5bd6a2 --- /dev/null +++ b/modules/LemDCBM400600/main/lem_dcbm_400600_controller.hpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#ifndef EVEREST_PODPOINT_LEMDCBM400600_H +#define EVEREST_PODPOINT_LEMDCBM400600_H + +#include "http_client_interface.hpp" +#include "lem_dcbm_time_sync_helper.hpp" +#include +#include +#include +#include +#include + +namespace module::main { + +class LemDCBM400600Controller { + +public: + struct Conf { + // number of retries to connect to powermeter at initialization + const int init_number_of_http_retries; + // wait time before each retry during powermeter at initialization + const int init_retry_wait_in_milliseconds; + // number of retries for failed requests (due to HTTP or device errors) to start or stop a transaction + const int transaction_number_of_http_retries; + // wait time before each retry for transaction start/stop requests + const int transaction_retry_wait_in_milliseconds; + }; + + class DCBMUnexpectedResponseException : public std::exception { + public: + const char* what() { + return this->reason.c_str(); + } + + explicit DCBMUnexpectedResponseException(std::string reason) : reason(std::move(reason)) { + } + + private: + std::string reason; + }; + + class UnexpectedDCBMResponseBody : public DCBMUnexpectedResponseException { + public: + UnexpectedDCBMResponseBody(std::string endpoint, std::string error) : + DCBMUnexpectedResponseException( + 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 UnexpectedDCBMResponseCode : public DCBMUnexpectedResponseException { + public: + const std::string endpoint; + const HttpResponse response; + const std::string body; + + UnexpectedDCBMResponseCode(const std::string& endpoint, unsigned int expected_code, + const HttpResponse& response) : + DCBMUnexpectedResponseException(fmt::format( + "Received unexpected response from endpoint '{}': {} (expected {}) {}", endpoint, response.status_code, + expected_code, !response.body.empty() ? " - body: " + response.body : "")), + endpoint(endpoint), + response(response) { + } + }; + +private: + const std::unique_ptr http_client; + std::string meter_id; + Conf config; + std::unique_ptr time_sync_helper; + + void fetch_meter_id_from_device(); + void request_device_to_start_transaction(const types::powermeter::TransactionReq& value); + void request_device_to_stop_transaction(const std::string& transaction_id); + std::string fetch_ocmf_result(const std::string& transaction_id); + void convert_livemeasure_to_powermeter(const std::string& livemeasure, types::powermeter::Powermeter& powermeter); + static std::string transaction_start_request_to_dcbm_payload(const types::powermeter::TransactionReq& request); + static std::pair get_transaction_stop_time_bounds(); + + template + 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_dcbm_reponse_error = true) + -> decltype(func()) { + std::exception_ptr lastException = nullptr; + for (int attempt = 0; attempt < 1 + number_of_retries; ++attempt) { + try { + return func(); + } catch (HttpClientError& http_client_error) { + lastException = std::current_exception(); + if (!retry_on_http_client_error) { + std::rethrow_exception(lastException); + } + 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 (DCBMUnexpectedResponseException& dcbm_error) { + lastException = std::current_exception(); + if (!retry_on_dcbm_reponse_error) { + std::rethrow_exception(lastException); + } + EVLOG_warning << "Unexpected DCBM response: " << dcbm_error.what() << "; retry in " + << retry_wait_in_milliseconds << " milliseconds"; + std::this_thread::sleep_for(std::chrono::milliseconds(retry_wait_in_milliseconds)); + } + } + std::rethrow_exception(lastException); + } + +public: + LemDCBM400600Controller() = delete; + + explicit LemDCBM400600Controller(std::unique_ptr http_client, + std::unique_ptr time_sync_helper, const Conf& config) : + http_client(std::move(http_client)), time_sync_helper(std::move(time_sync_helper)), config(config) { + } + + void init(); + types::powermeter::TransactionStartResponse start_transaction(const types::powermeter::TransactionReq& value); + types::powermeter::TransactionStopResponse stop_transaction(const std::string& transaction_id); + types::powermeter::Powermeter get_powermeter(); +}; + +} // namespace module::main + +#endif // EVEREST_PODPOINT_LEMDCBM400600_H diff --git a/modules/LemDCBM400600/main/lem_dcbm_time_sync_helper.cpp b/modules/LemDCBM400600/main/lem_dcbm_time_sync_helper.cpp new file mode 100644 index 000000000..28e77bd83 --- /dev/null +++ b/modules/LemDCBM400600/main/lem_dcbm_time_sync_helper.cpp @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#include "lem_dcbm_time_sync_helper.hpp" +#include "http_client_interface.hpp" +#include "lem_dcbm_400600_controller.hpp" +#include +#include +#include +#include +#include + +namespace module::main { + +std::string LemDCBMTimeSyncHelper::generate_dcbm_ntp_config() { + nlohmann::ordered_json config_json = { + {"ntp", + {{"servers", + {{{"ipAddress", ntp_spec.ip_addr_1}, {"port", ntp_spec.port_1}}, + {{"ipAddress", ntp_spec.ip_addr_2}, {"port", ntp_spec.port_2}}}}, + {"syncPeriod", 120}, + {"ntpActivated", true}}}, + }; + + return config_json.dump(); +} + +void LemDCBMTimeSyncHelper::sync_if_deadline_expired(const HttpClientInterface& httpClient) { + const std::lock_guard lock(this->time_sync_state_lock); + + if (this->ntp_spec.ntp_enabled && this->dcbm_ntp_settings_saved) { + return; + } + + if (std::chrono::steady_clock::now() >= this->deadline_for_next_sync) { + try { + this->sync(httpClient); + } catch (LemDCBM400600Controller::DCBMUnexpectedResponseException& error) { + EVLOG_warning << "Failed to sync time settings: " << error.what(); + this->deadline_for_next_sync = + std::chrono::steady_clock::now() + this->timing_constants.min_time_between_sync_retries; + } + } +} + +void LemDCBMTimeSyncHelper::sync(const HttpClientInterface& httpClient) { + const std::lock_guard lock(this->time_sync_state_lock); + + if (this->ntp_spec.ntp_enabled && this->dcbm_ntp_settings_saved) { + return; + } + + if (this->ntp_spec.ntp_enabled) { + this->set_ntp_settings_on_device(httpClient); + } else { + this->sync_system_time(httpClient); + } +} + +bool LemDCBMTimeSyncHelper::is_setting_write_safe() const { + if (!this->unsafe_period_start_time.has_value()) { + EVLOG_warning << "LEM DCBM 400/600: Time sync was attempted, but the unsafe period start time is not set."; + return false; + } + // According to LEM DCBM manual, no setting should be written earlier than 2 minutes after the DCBM is powered on + bool sync_is_too_early = std::chrono::steady_clock::now() < + unsafe_period_start_time.value() + timing_constants.min_time_before_setting_write_is_safe; + if (sync_is_too_early) { + EVLOG_warning << "LEM DCBM 400/600: Time sync was performed earlier than 2 minutes after initialization. " + "Time will be synced regardless, but it may not be reliably saved."; + } + return !sync_is_too_early; +} + +void LemDCBMTimeSyncHelper::set_ntp_settings_on_device(const HttpClientInterface& httpClient) { + + HttpResponse response = httpClient.put("/v1/settings", this->generate_dcbm_ntp_config()); + if (response.status_code != 200) { + throw LemDCBM400600Controller::UnexpectedDCBMResponseCode("/v1/settings", 200, response); + } + bool success = nlohmann::json::parse(response.body).at("result") == 1; + if (!success) { + throw LemDCBM400600Controller::UnexpectedDCBMResponseBody( + "/v1/settings", "NTP setting was rejected by the device, e.g. because of an ongoing transaction."); + } + if (!is_setting_write_safe()) { + this->deadline_for_next_sync = + std::chrono::steady_clock::now() + this->timing_constants.min_time_between_sync_retries; + } else { + this->dcbm_ntp_settings_saved = true; + } +} + +void LemDCBMTimeSyncHelper::sync_system_time(const HttpClientInterface& httpClient) { + std::string time_update = Everest::Date::to_rfc3339(date::utc_clock::now()); + HttpResponse response = httpClient.put("/v1/settings", std::string(R"({"time":{"utc":")") + time_update + R"("}})"); + + if (response.status_code != 200) { + throw LemDCBM400600Controller::UnexpectedDCBMResponseCode("/v1/settings", 200, response); + } + bool success = nlohmann::json::parse(response.body).at("result") == 1; + if (!success) { + throw LemDCBM400600Controller::UnexpectedDCBMResponseBody( + "/v1/settings", "Time setting was rejected by the device, e.g. because of an ongoing transaction."); + } + + if (is_setting_write_safe()) { + this->deadline_for_next_sync = + std::chrono::steady_clock::now() + this->timing_constants.deadline_increment_after_sync; + } else { + this->deadline_for_next_sync = + std::chrono::steady_clock::now() + this->timing_constants.min_time_between_sync_retries; + } +} +void LemDCBMTimeSyncHelper::restart_unsafe_period() { + this->unsafe_period_start_time = std::chrono::steady_clock::now(); + deadline_for_next_sync = unsafe_period_start_time.value() + timing_constants.min_time_before_setting_write_is_safe; +} +} // namespace module::main diff --git a/modules/LemDCBM400600/main/lem_dcbm_time_sync_helper.hpp b/modules/LemDCBM400600/main/lem_dcbm_time_sync_helper.hpp new file mode 100644 index 000000000..6fc707dac --- /dev/null +++ b/modules/LemDCBM400600/main/lem_dcbm_time_sync_helper.hpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#ifndef EVEREST_PODPOINT_LEM_DCBM_TIME_SYNC_HELPER_H +#define EVEREST_PODPOINT_LEM_DCBM_TIME_SYNC_HELPER_H + +#include "http_client_interface.hpp" +#include +#include +#include +#include +#include +#include + +namespace module::main { + +struct timing_config { + // This is the time after powerup after which the DCBM guarantees that writing to settings will be reliable + // Make sure to re-attempt any settings writes after this time has passed + std::chrono::seconds min_time_before_setting_write_is_safe = std::chrono::minutes(2); + // When performing regular syncs (e.g. as part of the livemeasure loop), this is the minimum duration between + // retries. + std::chrono::seconds min_time_between_sync_retries = std::chrono::minutes(1); + // When a sync is successful, advance the deadline for regular syncs by this much: + std::chrono::seconds deadline_increment_after_sync = std::chrono::hours(24); +}; + +struct ntp_server_spec { + const std::string ip_addr_1; + const int port_1 = 123; + const std::string ip_addr_2; + const int port_2 = 123; + const bool ntp_enabled = !ip_addr_1.empty(); +}; + +class LemDCBMTimeSyncHelper { + +public: + LemDCBMTimeSyncHelper() = delete; + + explicit LemDCBMTimeSyncHelper(ntp_server_spec ntp_spec) : + LemDCBMTimeSyncHelper(std::move(ntp_spec), timing_config{}) { + } + + explicit LemDCBMTimeSyncHelper(ntp_server_spec ntp_spec, timing_config tc) : + timing_constants(tc), ntp_spec(std::move(ntp_spec)), unsafe_period_start_time({}) { + } + + virtual void sync_if_deadline_expired(const HttpClientInterface& httpClient); + + virtual void sync(const HttpClientInterface& httpClient); + + virtual void restart_unsafe_period(); + +private: + // CONFIGURATION VARIABLES + const ntp_server_spec ntp_spec; + // Timing constants (can be overridden in a special constructor, e.g. during testing) + const timing_config timing_constants; + + // RUNNING VARIABLES + // The helper can be accessed by multiple threads, so we use a mutex to protect the data below + std::recursive_mutex time_sync_state_lock; + std::chrono::time_point deadline_for_next_sync; + std::optional> unsafe_period_start_time; + // True whenever the NTP config is successfully written to the device after min_time_before_setting_write_is_safe + // has passed + bool dcbm_ntp_settings_saved = false; + + // sync_is_too_early is set if this is done earlier than MIN_TIME_BEFORE_SETTING_WRITE_IS_SAFE after init + void set_ntp_settings_on_device(const HttpClientInterface& httpClient); + + // sync_is_too_early is set if this is done earlier than MIN_TIME_BEFORE_SETTING_WRITE_IS_SAFE after init + void sync_system_time(const HttpClientInterface& httpClient); + + std::string generate_dcbm_ntp_config(); + [[nodiscard]] bool is_setting_write_safe() const; +}; + +} // namespace module::main + +#endif // EVEREST_PODPOINT_LEM_DCBM_TIME_SYNC_HELPER_H diff --git a/modules/LemDCBM400600/main/powermeterImpl.cpp b/modules/LemDCBM400600/main/powermeterImpl.cpp new file mode 100644 index 000000000..8839b58d7 --- /dev/null +++ b/modules/LemDCBM400600/main/powermeterImpl.cpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#include "powermeterImpl.hpp" +#include "http_client.hpp" +#include "lem_dcbm_time_sync_helper.hpp" +#include +#include +#include + +namespace module::main { + +void powermeterImpl::init() { + // Dependency injection pattern: Create the HTTP client first, + // then move it into the controller as a constructor argument + auto http_client = + std::make_unique(mod->config.ip_address, mod->config.port, mod->config.meter_tls_certificate); + + auto ntp_server_spec = + module::main::ntp_server_spec{mod->config.ntp_server_1_ip_addr, mod->config.ntp_server_1_port, + mod->config.ntp_server_2_ip_addr, mod->config.ntp_server_2_port}; + + this->controller = std::make_unique( + std::move(http_client), std::make_unique(ntp_server_spec), + LemDCBM400600Controller::Conf{mod->config.resilience_initial_connection_retries, + mod->config.resilience_initial_connection_retry_delay, + mod->config.resilience_transaction_request_retries, + mod->config.resilience_transaction_request_retry_delay}); + + this->controller->init(); +} + +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) { + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); + try { + this->publish_powermeter(this->controller->get_powermeter()); + } catch (LemDCBM400600Controller::DCBMUnexpectedResponseException& dcbm_exception) { + EVLOG_error << "Failed to publish powermeter value due to an invalid device response: " + << dcbm_exception.what(); + } catch (HttpClientError& client_error) { + EVLOG_error << "Failed to publish powermeter value due to an http error: " << client_error.what(); + } + } + }); +} + +types::powermeter::TransactionStartResponse +powermeterImpl::handle_start_transaction(types::powermeter::TransactionReq& value) { + return this->controller->start_transaction(value); +} + +types::powermeter::TransactionStopResponse powermeterImpl::handle_stop_transaction(std::string& transaction_id) { + return this->controller->stop_transaction(transaction_id); +} + +} // namespace module::main diff --git a/modules/LemDCBM400600/main/powermeterImpl.hpp b/modules/LemDCBM400600/main/powermeterImpl.hpp new file mode 100644 index 000000000..779c24aa6 --- /dev/null +++ b/modules/LemDCBM400600/main/powermeterImpl.hpp @@ -0,0 +1,74 @@ +// 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 + +#include "../LemDCBM400600.hpp" + +// ev@75ac1216-19eb-4182-a85c-820f1fc2c091:v1 +// insert your custom include headers here +#include "http_client_interface.hpp" +#include "lem_dcbm_400600_controller.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& 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& mod; + const Conf& config; + + virtual void init() override; + virtual void ready() override; + + // ev@3370e4dd-95f4-47a9-aaec-ea76f34a66c9:v1 + // insert your private definitions here + + // 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 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; + // 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 diff --git a/modules/LemDCBM400600/manifest.yaml b/modules/LemDCBM400600/manifest.yaml new file mode 100644 index 000000000..662e708f9 --- /dev/null +++ b/modules/LemDCBM400600/manifest.yaml @@ -0,0 +1,54 @@ +description: Module implementing the LEM DCBM 400/600 power meter driver adapter via HTTP. +config: + ip_address: + description: IP Address of the power meter API. + type: string + port: + description: Port of the power meter API. + type: integer + default: 80 + meter_tls_certificate: + description: The DCBM's HTTPS certificate, in PEM format. If provided, HTTPS will be used. If left empty, regular HTTP will be used. Note that this does not affect the default port - specify a port explicitly if you wish to use a port other than 80. + type: string + default: "" + ntp_server_1_ip_addr: + description: The IPv4 address (in 4-octet form W.X.Y.Z) of the first NTP server to use for time sync. If this is left empty, NTP will not be configured on the DCBM - its time will be synced with EVerest's system time instead. + type: string + default: "" + ntp_server_1_port: + description: The port (1-65535) of the first NTP server. + type: integer + default: 123 + ntp_server_2_ip_addr: + description: The IPv4 address (in 4-octet form W.X.Y.Z) of the second NTP server to use for time sync. This is ignored if ntp_server_1_ip_addr is empty. + type: string + default: "" + ntp_server_2_port: + description: The port (1-65535) fof the second NTP server. + type: integer + default: 123 + resilience_initial_connection_retries: + description: For the controller resilience, the number of retries to connect to the powermeter at module initialization. + type: integer + default: 25 + resilience_initial_connection_retry_delay: + description: For the controller resilience, the delay in milliseconds before a retry attempt at module initialization.. + type: integer + 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 + 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 + default: 250 +provides: + main: + description: This is the main unit of the module + interface: powermeter +metadata: + license: https://opensource.org/licenses/Apache-2.0 + authors: + - Valentin Dimov, valentin.dimov@pionix.de + - Fabian Klemm, fabian.klemm@pionix.de diff --git a/modules/LemDCBM400600/tests/CMakeLists.txt b/modules/LemDCBM400600/tests/CMakeLists.txt new file mode 100644 index 000000000..7f235bd17 --- /dev/null +++ b/modules/LemDCBM400600/tests/CMakeLists.txt @@ -0,0 +1,91 @@ + +add_executable(test_lem_dcbm_400600_controller + test_lem_dcbm_400600_controller.cpp + "${PROJECT_SOURCE_DIR}/modules/LemDCBM400600/main/lem_dcbm_400600_controller.cpp" + "${PROJECT_SOURCE_DIR}/modules/LemDCBM400600/main/lem_dcbm_time_sync_helper.cpp" +) + +set(INCLUDE_DIR + "main" + "tests" + "${PROJECT_SOURCE_DIR}/modules/LemDCBM400600/main" + "${PROJECT_SOURCE_DIR}/modules/LemDCBM400600/tests") + +get_target_property(GENERATED_INCLUDE_DIR generate_cpp_files EVEREST_GENERATED_INCLUDE_DIR) + +find_package(GTest REQUIRED) + +target_include_directories(test_lem_dcbm_400600_controller PRIVATE + tests + ${INCLUDE_DIR} + ${GENERATED_INCLUDE_DIR} +) + +# +target_link_libraries(test_lem_dcbm_400600_controller PRIVATE + GTest::gmock_main + everest::timer + everest::framework + nlohmann_json::nlohmann_json + CURL::libcurl +) + + +add_test(test_lem_dcbm_400600_controller test_lem_dcbm_400600_controller) + + +## Time sync helper test +add_executable(test_lem_time_sync_helper + test_lem_dcbm_time_sync_helper.cpp + "${PROJECT_SOURCE_DIR}/modules/LemDCBM400600/main/lem_dcbm_time_sync_helper.cpp" +) + +set(INCLUDE_DIR + "main" + "tests" + "${PROJECT_SOURCE_DIR}/modules/LemDCBM400600/main" + "${PROJECT_SOURCE_DIR}/modules/LemDCBM400600/tests") + +get_target_property(GENERATED_INCLUDE_DIR generate_cpp_files EVEREST_GENERATED_INCLUDE_DIR) + +find_package(GTest REQUIRED) + +target_include_directories(test_lem_time_sync_helper PRIVATE + tests + ${INCLUDE_DIR} + ${GENERATED_INCLUDE_DIR} +) + +# +target_link_libraries(test_lem_time_sync_helper PRIVATE + GTest::gmock_main + everest::timer + everest::framework + nlohmann_json::nlohmann_json + CURL::libcurl +) + +add_test(test_lem_dcbm_time_sync_helper test_lem_dcbm_time_sync_helper) + + +## http client integration client + +add_executable(integration_test_http_client + integration_test_http_client.cpp + "${PROJECT_SOURCE_DIR}/modules/LemDCBM400600/main/http_client.cpp" +) + +target_include_directories(integration_test_http_client PRIVATE + tests + ${INCLUDE_DIR} + ${GENERATED_INCLUDE_DIR} +) + +# +target_link_libraries(integration_test_http_client PRIVATE + GTest::gmock_main + everest::timer + everest::framework + nlohmann_json::nlohmann_json + CURL::libcurl +) diff --git a/modules/LemDCBM400600/tests/README.md b/modules/LemDCBM400600/tests/README.md new file mode 100644 index 000000000..1a84a7f59 --- /dev/null +++ b/modules/LemDCBM400600/tests/README.md @@ -0,0 +1,85 @@ +## Unit & Integration Tests with GTest + +A series of unit tests test the implemented business logic of the controller. For the http client that wraps +libcurl, integration tests can be used to test succesful communication with the device. + +### Requirements for unit/integration tests + +The GTest unit tests require GTest. This can be installed via +```bash +apt install libgtest-dev +``` + +### Build + +Build the module with the flag `LEMDCBM_BUILD_TESTS:BOOL=ON`, e.g. via + +```bash +export CMAKE_PREFIX_PATH= +mkdir -p build +cd build +cmake -DLEMDCBM_BUILD_TESTS:BOOL=ON .. +make -j 10 +``` + +### Run Unit tests + +In the build directory, run +```bash +./modules/LemDCBM400600/tests/test_lem_dcbm_400600_controller +``` + +### Run HTTPClient Integration Tests + +Note: The integration test require the configured backend (in form of an actual LEM DCBM oder the Mock) to be running +at the configured address and port. + +To start the mocked API, run +```bash +python3 /modules/LemDCBM400600/utils/lem_dcbm_api_mock/main.py +``` + +To then run the http client integration tests, run in the build directory +```bash +./modules/LemDCBM400600/tests/integration_test_http_client +``` +## Integration / E2E Tests for LemDCBM400600 (Python wrapped) + +The integration / E2E tests built on the integration test tools from `everest-core/tests` allow to test +the module from inside EVerest both against the mock (integration test) and the actual device (e2e test). + +### Requirements for E2E tests + +- Module built & installed into /dist + +- Everest testing utils installed; cf. everst-core/tests/Readme.md + +- Further, this requires the following installed packages in the used Python interpreter + ```bash + pip install fastapi uvicorn pyyaml + ``` + +If not done before set the Cmake install prefix, for example via +```bash +CMAKE_INSTALL_PREFIX=/dist +``` +then build and install the tool again (`cmake build`, `make`, `make install`; in the end, the $CMAKE_INSTALL_PREFIX directory +should contain the installed binaries) + +### Run E2E tests + +In `modules/LemDCBM400600/tests`, run: +```bash +python3 -m pytest --everest-prefix=$CMAKE_INSTALL_PREFIX test_lem_dcbm_400_600_sil.py +python3 -m pytest --lem-dcbm-host 10.8.8.24 --lem-dcbm-port 5566 --everest-prefix=$CMAKE_INSTALL_PREFIX test_lem_dcbm_400_600_e2e.py +``` +(here, for the e2e test substitute appropriate values of the actual test device) + +*Note* Due to a behavior of the `EverestCore` testing class from everest-utils, it is not possible +to quote escape strings in configuration yamls; this leads to an unexpected behavior since an unquoted +ip address (such as 127.0.0.1) will fail the EVerest type check. A local workaround is a +host entry in `/etc/hosts`, such as +```bash +10.8.8.24 lemdcbm +``` +and then use `lemdcbm` instead of the IP address. diff --git a/modules/LemDCBM400600/tests/conftest.py b/modules/LemDCBM400600/tests/conftest.py new file mode 100644 index 000000000..1ff48c857 --- /dev/null +++ b/modules/LemDCBM400600/tests/conftest.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2020 - 2023 Pionix GmbH and Contributors to EVerest +import logging +import sys +import time +from pathlib import Path + +import pytest + +sys.path.append(str(Path(__file__).parent / "../utils")) + +from lem_dcbm_api_mock.main import app as lem_api_mock +import uvicorn +from multiprocessing import Process + + +def pytest_addoption(parser): + parser.addoption("--everest-prefix", action="store", default="../build/dist", + help="everest prefix path; default = '../build/dist'") + parser.addoption("--lem-dcbm-host", action="store", + help="Address of LEM DCBM 400/600") + parser.addoption("--lem-dcbm-port", action="store", + help="Port of LEM DCBM 400/600") + + +@pytest.fixture(scope="module") +def lem_dcbm_mock(): + # Start the server in a subprocess + server = Process(target=uvicorn.run, + args=(lem_api_mock,), + kwargs={ + "host": "0.0.0.0", + "port": 8000, + "log_level": "info"}, + daemon=True) + try: + server.start() + time.sleep(0.1) # Allow some time for the server to start + assert server.is_alive() + logging.info("started up lem dcbm api mock server") + yield # This is where the testing happens + + # After the tests, terminate the server + finally: + server.terminate() diff --git a/modules/LemDCBM400600/tests/integration_test_http_client.cpp b/modules/LemDCBM400600/tests/integration_test_http_client.cpp new file mode 100644 index 000000000..5ca6dcf5c --- /dev/null +++ b/modules/LemDCBM400600/tests/integration_test_http_client.cpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#include "http_client.hpp" +#include +#include +#include + +namespace module::main { + +class HttpClientIntegrationTest : public ::testing::Test {}; + +static const std::string HOST = "localhost"; +static int HTTP_PORT = 8000; +static int HTTPS_PORT = 8443; +const char* MOCK_API_TLS_CERT_CONTENTS = "MIIDazCCAlOgAwIBAgIUHDu1ZdpL229xmwqrmq/oq9YQaYwwDQYJKoZIhvcNAQEL" + "BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM" + "GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA5MTQxMjAxMDhaFw0yNDA5" + "MTMxMjAxMDhaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw" + "HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB" + "AQUAA4IBDwAwggEKAoIBAQDCES0SIQSMzKi6aIuLNkjXUj1/eGjuAV2qLcPiaRe3" + "GYRy+tDS4wJb0JxdU2JYMzGrq3tNGcm6E/bXpkJWjB1znFhd+6wr077KV+ryMfBa" + "QwE7uIOnj4XeIBRhU9QvgSF3vfvoxOEKsq6X+cXPlAelbnMrIXniL4lwLNJD2UAl" + "eNYmJFKIJfZPmnNKLQwZkvIL8H5G134KMvOh2AVG1EHuzUBoKs72d77TI6UsITu9" + "/PeATVxm9hhRpk1tuq/NLoUHTqgUPsfN83zeCm7buOOmQpJsypFz5lVmLmtq7YY3" + "+vu4+IuUQegJUC+eXH+WXrh1gkCpNre/S7KgS0FWnJD3AgMBAAGjUzBRMB0GA1Ud" + "DgQWBBQ/YFwElfxomN+kQvtf4tTjU4XGrzAfBgNVHSMEGDAWgBQ/YFwElfxomN+k" + "Qvtf4tTjU4XGrzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAZ" + "K9/4szwsoeTQbPxeDNKNeBRrdHhVOtC3PLP2O0eqZkogTFE4PhreL7S+Q4INbrUh" + "Pw/mZ9FwfsyHVupJWUBgPZx9kSflAJHFG7rikY13UenLmYNU4lGsoJQEewLw+wT1" + "jfJgW/LXZ2He1dMsp3IVyNjR62BtZyI4B9ArUxyILpSSsczk7XN4oEkWDCTATP7t" + "VfsKaM6eIfSnY11g1koVjGy+YtdcO5GJ/6Q7va1BuT3PzD3GjcxPZfhVu3rJBupl" + "0p0LoiBSxpcepMYag5zguxoyU78FKdShFyl5lnFUtAWVD9Hi1+M/znYwiXpS6EGc" + "DW+bAzWAH3M1KKV2UUTa"; +const std::string MOCK_API_TLS_CERT_NO_NEWLINES = + std::string("-----BEGIN CERTIFICATE-----") + MOCK_API_TLS_CERT_CONTENTS + "-----END CERTIFICATE-----"; +const std::string MOCK_API_TLS_CERT_BOTH_NEWLINES = + std::string("-----BEGIN CERTIFICATE-----\n") + MOCK_API_TLS_CERT_CONTENTS + "\n-----END CERTIFICATE-----"; +const std::string MOCK_API_TLS_CERT_FIRST_NEWLINE = + std::string("-----BEGIN CERTIFICATE-----\n") + MOCK_API_TLS_CERT_CONTENTS + "-----END CERTIFICATE-----"; +const std::string MOCK_API_TLS_CERT_SECOND_NEWLINE = + std::string("-----BEGIN CERTIFICATE-----") + MOCK_API_TLS_CERT_CONTENTS + "\n-----END CERTIFICATE-----"; + +// \brief Test get_powermeter returns correct its status including meter_Id +TEST_F(HttpClientIntegrationTest, test_status) { + + HttpClient client(HOST, HTTP_PORT, ""); + auto res = client.get("/v1/status"); + + EXPECT_EQ(200, res.status_code); + + auto json = nlohmann::json::parse(res.body); + + EXPECT_THAT(json.at("meterId").size(), testing::Gt(0)); +} + +/// \brief Test get_powermeter returns correct live measure +TEST_F(HttpClientIntegrationTest, test_get_livemeasure) { + + HttpClient client(HOST, HTTP_PORT, ""); + auto res = client.get("/v1/livemeasure"); + + EXPECT_EQ(200, res.status_code); + + auto json = nlohmann::json::parse(res.body); + + EXPECT_THAT(json.at("timestamp").size(), testing::Gt(0)); +} + +TEST_F(HttpClientIntegrationTest, test_put_legal) { + + HttpClient client(HOST, HTTP_PORT, ""); + auto res = client.put("/v1/legal?transactionId=test_transaction", R"({"running": false})"); + + EXPECT_EQ(200, res.status_code); + + auto json = nlohmann::json::parse(res.body); + + EXPECT_EQ(json.at("transactionId"), "test_transaction"); +} + +TEST_F(HttpClientIntegrationTest, test_post_legal) { + + HttpClient client(HOST, HTTP_PORT, ""); + auto res = client.post("/v1/legal", R"({ + "evseId": "string", + "transactionId": "test_transaction", + "clientId": "string", + "tariffId": 0, + "cableId": 0, + "userData": "string" + })"); + + EXPECT_EQ(201, res.status_code); + + auto json = nlohmann::json::parse(res.body); + + EXPECT_EQ(json.at("transactionId"), "test_transaction"); + EXPECT_EQ(json.at("running").get(), true); +} + +/// \brief Test get_powermeter returns correct live measure +TEST_F(HttpClientIntegrationTest, test_get_livemeasure_tls) { + + HttpClient client(HOST, HTTPS_PORT, MOCK_API_TLS_CERT_BOTH_NEWLINES); + auto res = client.get("/v1/livemeasure"); + + EXPECT_EQ(200, res.status_code); + + auto json = nlohmann::json::parse(res.body); + + EXPECT_THAT(json.at("timestamp").size(), testing::Gt(0)); +} + +TEST_F(HttpClientIntegrationTest, test_put_legal_tls) { + + HttpClient client(HOST, HTTPS_PORT, MOCK_API_TLS_CERT_BOTH_NEWLINES); + auto res = client.put("/v1/legal?transactionId=test_transaction", R"({"running": false})"); + + EXPECT_EQ(200, res.status_code); + + auto json = nlohmann::json::parse(res.body); + + EXPECT_EQ(json.at("transactionId"), "test_transaction"); +} + +TEST_F(HttpClientIntegrationTest, test_post_legal_tls) { + + HttpClient client(HOST, HTTPS_PORT, MOCK_API_TLS_CERT_BOTH_NEWLINES); + auto res = client.post("/v1/legal", R"({ + "evseId": "string", + "transactionId": "test_transaction", + "clientId": "string", + "tariffId": 0, + "cableId": 0, + "userData": "string" + })"); + + EXPECT_EQ(201, res.status_code); + + auto json = nlohmann::json::parse(res.body); + + EXPECT_EQ(json.at("transactionId"), "test_transaction"); + EXPECT_EQ(json.at("running").get(), true); +} + +class HttpClientIntegrationTestWithCert : public ::testing::TestWithParam { +protected: + std::string cert; +}; + +/// \brief Test that the module fixes missing newlines correctly +TEST_P(HttpClientIntegrationTestWithCert, test_fix_missing_newlines_in_cert) { + std::string cert = GetParam(); + HttpClient client(HOST, HTTPS_PORT, cert); + auto res = client.get("/v1/livemeasure"); + EXPECT_EQ(200, res.status_code); +} + +INSTANTIATE_TEST_SUITE_P(HttpFixCertNewlinesTests, HttpClientIntegrationTestWithCert, + ::testing::Values(MOCK_API_TLS_CERT_NO_NEWLINES, MOCK_API_TLS_CERT_FIRST_NEWLINE, + MOCK_API_TLS_CERT_SECOND_NEWLINE)); +} // namespace module::main diff --git a/modules/LemDCBM400600/tests/lem_dcbm_test_utils/dcbm.py b/modules/LemDCBM400600/tests/lem_dcbm_test_utils/dcbm.py new file mode 100644 index 000000000..a43990499 --- /dev/null +++ b/modules/LemDCBM400600/tests/lem_dcbm_test_utils/dcbm.py @@ -0,0 +1,138 @@ +import asyncio +import logging +import ssl +from datetime import datetime, timezone +from http.client import HTTPSConnection +from urllib.parse import urlparse + +import requests +from pydantic import BaseModel + + +class DCBMInterface: + + def __init__(self, host: str, port: int, enable_tls=False): + self.host = host + self.port = port + self.enable_tls = enable_tls + + @property + def _base_url(self) -> str: + return f"{'https' if self.enable_tls else 'http'}://{self.host}:{self.port}" + + async def wait_for_status(self, target_status: int, timeout=5, ) -> int: + async def check(): + while requests.get(f"{self._base_url}/v1/status", verify=False).json()["status"][ + "value"] != target_status: + await asyncio.sleep(1) + return + + try: + await asyncio.wait_for(check(), timeout) + return True + except asyncio.exceptions.TimeoutError: + return False + + def activate_tls_via_http(self): + if self._is_https_running(): + return + logging.info("enabling tls on dcbm device") + res = requests.put(f"http://{self.host}:{self.port}/v1/settings", json={ + "http": { + "tls_on": True, + } + }) + assert res.ok + + def deactivate_tls_via_https(self): + if not self._is_https_running(): + return + logging.info("disabling tls on dcbm device") + res = requests.put(f"https://{self.host}:{self.port}/v1/settings", json={ + "http": { + "tls_on": False + } + }, verify=False) + assert res.ok + + def _is_https_running(self) -> bool: + try: + HTTPS_URL = urlparse(f"https://{self.host}:{self.port}") + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.set_ciphers('ALL:@SECLEVEL=1') + connection = HTTPSConnection(HTTPS_URL.netloc, timeout=2, context=ctx) + connection.request('HEAD', HTTPS_URL.path) + if connection.getresponse(): + return True + else: + return False + except: + return False + + def stop_any_ongoing_transaction(self): + ongoing = requests.get(f"{self._base_url}/v1/status", verify=False).json()["status"]["bits"][ + "transactionIsOnGoing"] + if ongoing: + transaction = requests.get(f"{self._base_url}/v1/legal", verify=False).json()["transactionId"] + logging.warning(f"stopping transaction {transaction}") + stop_res = requests.put(f"{self._base_url}/v1/legal?transactionId={transaction}", verify=False, + json={ + "running": False + }) + assert stop_res.ok + asyncio.run(self.wait_for_status(17)) + + def reset_device(self): + """ Reset to http; stop any ongoing transaction + """ + logging.info("reset DCBM device settings to http; stopping any transaction") + try: + self.deactivate_tls_via_https() + except: + pass + self.stop_any_ongoing_transaction() + self.disable_ntp() + self.set_time(datetime.utcnow()) + + def get_certificate(self) -> str: + return requests.get(f"http://{self.host}:{self.port}/v1/certificate").json()["certificate"] + + class DCBMStatus(BaseModel): + status: dict + time: datetime + + def get_status(self) -> DCBMStatus: + return self.DCBMStatus(**requests.get(f"{self._base_url}/v1/status", verify=False).json()) + + def set_time(self, time: datetime): + assert requests.put(f"{self._base_url}/v1/settings", verify=False, + json={"time": { + "utc": time.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + }}).ok + + def disable_ntp(self): + + assert requests.put(f"{self._base_url}/v1/settings", verify=False, + json={"ntp": { + "servers": [ + { + "ipAddress": "", + "port": 123 + }, + { + "ipAddress": "", + "port": 123 + } + ], + "ntpActivated": False} + }).ok + + class DCBMNtpSettings(BaseModel): + servers: list[dict] + ntpActivated: bool + + def get_ntp_settings(self) -> DCBMNtpSettings: + return self.DCBMNtpSettings(**requests.get(f"{self._base_url}/v1/settings", verify=False, + ).json()["ntp"]) diff --git a/modules/LemDCBM400600/tests/lem_dcbm_test_utils/everest.py b/modules/LemDCBM400600/tests/lem_dcbm_test_utils/everest.py new file mode 100644 index 000000000..3d991d054 --- /dev/null +++ b/modules/LemDCBM400600/tests/lem_dcbm_test_utils/everest.py @@ -0,0 +1,94 @@ +import asyncio +import contextlib +import logging +from datetime import datetime +from pathlib import Path +from tempfile import NamedTemporaryFile + +import yaml +from everest.framework import RuntimeSession +from everest.testing.core_utils.everest_core import EverestCore, Requirement +from pydantic import BaseModel, Extra, Field + +from lem_dcbm_test_utils.probe_module import ProbeModule + + +class LecmDDCBMModuleConfig(BaseModel): + ip_address: str + port: int + meter_tls_certificate: str | None = None + ntp_server_1_ip_addr: str | None = None + ntp_server_1_port: int | None = None + ntp_server_2_ip_addr: str | None = None + ntp_server_2_port: int | None = None + +class StartTransactionSuccessResponse(BaseModel, extra=Extra.forbid): + status: str = Field("OK", const=True, strict=True) + transaction_max_stop_time: datetime + transaction_min_stop_time: datetime + + +class StopTransactionSuccessResponse(BaseModel, extra=Extra.forbid): + status: str = Field("OK", const=True, strict=True) + ocmf: str = Field(regex=r"^OCMF|.*|.*$", strict=True) + + +class LemDCBMStandaloneEverestInstance(contextlib.ContextDecorator): + + def __init__(self, everest_prefix: Path, config: LecmDDCBMModuleConfig): + self.everest_prefix = everest_prefix + self.config = config + self._stack = None + self._stack = None + + + def _write_config(self, target_file: Path): + template_file = Path(__file__).parent / "../resources/config-standalone-lemdcbm400600.yaml" + config = yaml.safe_load(template_file.read_text(encoding="utf-8")) + module_config = config["active_modules"]["lem_dcbm_controller"]["config_module"] = { + **config["active_modules"]["lem_dcbm_controller"]["config_module"], + **self.config.dict(exclude_none=True) + } + + logging.info(f"writing config ip_address={self.config.ip_address} port={self.config.port} into {target_file}") + with target_file.open("w") as f: + yaml.dump(config, f) + + def __enter__(self): + self._stack = contextlib.ExitStack() + file = Path(self._stack.enter_context(NamedTemporaryFile()).name) + self._write_config(file) + self._everest = EverestCore(self.everest_prefix, file) + self._everest.start(standalone_module='probe', test_connections={ + 'test_control': [Requirement('lem_dcbm_controller', 'main')] + }) + if self._everest.status_listener.wait_for_status(3, ["ALL_MODULES_STARTED"]): + self._everest.all_modules_started_event.set() + logging.info("set all modules started event...") + self._probe_module = self._create_probe_module() + + return self + + def __exit__(self, *exc): + self._everest.stop() + self._stack.__exit__(*exc) + self._stack = None + self._everest = None + self._probe_module = None + return False + + @property + def probe_module(self) -> ProbeModule: + assert self._probe_module + return self._probe_module + + def _get_session(self) -> RuntimeSession: + assert self._everest + return RuntimeSession(str(self._everest.prefix_path), + str(self._everest.everest_config_path)) + + def _create_probe_module(self) -> ProbeModule: + session = self._get_session() + module = ProbeModule(session) + asyncio.run(module.wait_to_be_ready()) + return module diff --git a/modules/LemDCBM400600/tests/lem_dcbm_test_utils/probe_module.py b/modules/LemDCBM400600/tests/lem_dcbm_test_utils/probe_module.py new file mode 100644 index 000000000..a20d6e7cc --- /dev/null +++ b/modules/LemDCBM400600/tests/lem_dcbm_test_utils/probe_module.py @@ -0,0 +1,48 @@ +import asyncio +import logging +from asyncio.queues import Queue +from typing import Any + +from everest.framework import Module, RuntimeSession + +from lem_dcbm_test_utils.types import Powermeter + + +class ProbeModule: + def __init__(self, session: RuntimeSession): + logging.info("ProbeModule init start") + m = Module('probe', session) + self._setup = m.say_hello() + self._mod = m + + # subscribe to session events + logging.info(self._setup.connections) + evse_manager_ff = self._setup.connections['test_control'][0] + self._mod.subscribe_variable(evse_manager_ff, 'powermeter', + self._handle_evse_manager_powermeter_message) + + self._msg_queue = Queue() + self._ready_event = asyncio.Event() + m.init_done(self._ready) + logging.info("ProbeModule init done") + + def _ready(self): + logging.info("ProbeModule ready") + self._ready_event.set() + + def _handle_evse_manager_powermeter_message(self, message): + asyncio.run(self._msg_queue.put(message)) + + async def poll_next_powermeter(self, timeout) -> Powermeter: + return Powermeter(**(await asyncio.wait_for(self._msg_queue.get(), timeout=timeout))) + + def call_powermeter_command(self, command_name: str, args: dict) -> Any: + lem_ff = self._setup.connections['test_control'][0] + try: + return self._mod.call_command(lem_ff, command_name, args) + except Exception as e: + logging.info(f"Exception in calling command {command_name}: {type(e)}: {e}") + raise e + + async def wait_to_be_ready(self, timeout=3): + await asyncio.wait_for(self._ready_event.wait(), timeout) diff --git a/modules/LemDCBM400600/tests/lem_dcbm_test_utils/types.py b/modules/LemDCBM400600/tests/lem_dcbm_test_utils/types.py new file mode 100644 index 000000000..b28f5a12a --- /dev/null +++ b/modules/LemDCBM400600/tests/lem_dcbm_test_utils/types.py @@ -0,0 +1,48 @@ +from datetime import datetime + +from pydantic import BaseModel, Extra + + +class UnitCurrent(BaseModel, extra=Extra.forbid): + DC: float | None + L1: float | None + L2: float | None + L3: float | None + N: float | None + + +class UnitVoltage(BaseModel, extra=Extra.forbid): + DC: float | None + L1: float | None + L2: float | None + L3: float | None + + +class UnitFrequency(BaseModel, extra=Extra.forbid): + L1: float | None + L2: float | None + L3: float | None + + +class UnitPower(BaseModel, extra=Extra.forbid): + total: float + L1: float | None + L2: float | None + L3: float | None + + +class UnitEnergy(BaseModel, extra=Extra.forbid): + total: float + L1: float | None + L2: float | None + L3: float | None + + +class Powermeter(BaseModel, extra=Extra.forbid): + current_A: UnitCurrent + energy_Wh_export: UnitEnergy + energy_Wh_import: UnitEnergy + meter_id: str + power_W: UnitPower + timestamp: datetime + voltage_V: UnitVoltage diff --git a/modules/LemDCBM400600/tests/pytest.ini b/modules/LemDCBM400600/tests/pytest.ini new file mode 100644 index 000000000..40d00c73e --- /dev/null +++ b/modules/LemDCBM400600/tests/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +log_cli=true +log_level=debug +asyncio_mode=strict diff --git a/modules/LemDCBM400600/tests/resources/config-standalone-lemdcbm400600.yaml b/modules/LemDCBM400600/tests/resources/config-standalone-lemdcbm400600.yaml new file mode 100644 index 000000000..be4e05075 --- /dev/null +++ b/modules/LemDCBM400600/tests/resources/config-standalone-lemdcbm400600.yaml @@ -0,0 +1,8 @@ +settings: + telemetry_enabled: true +active_modules: + lem_dcbm_controller: + config_module: + ip_address: "localhost" + port: 8000 + module: LemDCBM400600 diff --git a/modules/LemDCBM400600/tests/test_lem_dcbm_400600_controller.cpp b/modules/LemDCBM400600/tests/test_lem_dcbm_400600_controller.cpp new file mode 100644 index 000000000..f0d24dc2b --- /dev/null +++ b/modules/LemDCBM400600/tests/test_lem_dcbm_400600_controller.cpp @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +// Unit tests for the LemDCBM400600Controller + +#include "http_client_interface.hpp" +#include "lem_dcbm_400600_controller.hpp" +#include +#include + +#include + +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)); + MOCK_METHOD(HttpResponse, put, (const std::string& path, const std::string& body), (override, const)); +}; + +class LemDCBMTimeSyncHelperMock : public LemDCBMTimeSyncHelper { +public: + MOCK_METHOD(void, sync_if_deadline_expired, (const HttpClientInterface& httpClient), (override)); + MOCK_METHOD(void, sync, (const HttpClientInterface& httpClient), (override)); + MOCK_METHOD(void, restart_unsafe_period, (), (override)); + LemDCBMTimeSyncHelperMock() : LemDCBMTimeSyncHelper({}, {}){}; +}; + +// Fixture class providing +// - a http client mock +// - default responses & request objects +class LemDCBM400600ControllerTest : public ::testing::Test { + +protected: + std::unique_ptr http_client; + std::unique_ptr time_sync_helper; + + const std::string livemeasure_response{R"({ + "voltage": 4.2, + "current": 4, + "power": 3, + "temperatureH": 0, + "temperatureL": 0, + "energyImportTotal": 1, + "energyExportTotal": 2, + "timestamp": "2023-09-10T21:10:08.068773" + })"}; + + const types::powermeter::TransactionReq transaction_request{ + "mock_evse_id", "mock_transaction_id", "mock_client_id", 42, 43, "mock_user_data"}; + + const std::string expected_start_transaction_request_body{ + R"({"evseId":"mock_evse_id","transactionId":"mock_transaction_id","clientId":"mock_client_id","tariffId":42,"cableId":43,"userData":"mock_user_data"})"}; + + const std::string put_legal_response = R"({ + "paginationCounter": 6, + "transactionId": "mock_transaction_id", + "evseId": "+49*DEF*E123ABC", + "clientId": "C12", + "tariffId": 2, + "cableSp": { + "cableSpName": "2mR_Comp", + "cableSpId": 1, + "cableSpRes": 2 + }, + "userData": "", + "meterValue": { + "timestampStart": "2020-12-10T16:39:15+01:00", + "timestampStop": "2020-12-10T16:39:15+01:00", + "transactionDuration": 70, + "intermediateRead": false, + "transactionStatus": 17, + "sampleValue": { + "energyUnit": "kWh", + "energyImport": 7.637, + "energyImportTotalStart": 188.977, + "energyImportTotalStop": 196.614, + "energyExport": 0.000, + "energyExportTotalStart": 0.000, + "energyExportTotalStop": 0.000 + }}, + "meterId": "12024072805", + "signature": "304502203DC38FBC722D216568D6ECB4B352577A999B6D184EA6AD48BDCAE7766DB1D628022100A7687B4CB5573829D407DD4B17D41C297917B7E8307E5017711B5A3A987F6801", + "publicKey": "A80F10D968E1122F8820F288B23C4E1C0DA912F35B48481274ADFEFE66D7E87E130C7CF2B8047C45CF105041C8C3A57DD242782F755C9443F42DABA9404A67BF" + })"; + + const LemDCBM400600Controller::Conf controller_config{0, 0, 1, 0}; + + void SetUp() override { + this->http_client = std::make_unique(); + this->time_sync_helper = std::make_unique(); + } +}; + +// Extended fixture for parametrizing tests for invalid response checks +class LemDCBM400600ControllerTestInvalidResponses + : public LemDCBM400600ControllerTest, + public ::testing::WithParamInterface> {}; + +//**************************************************************** +// Test get_powermeter behavior + +/// \brief Test get_powermeter returns correct live measure +TEST_F(LemDCBM400600ControllerTest, test_get_powermeter) { + + // Setup + testing::Sequence seq; + EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, get("/v1/livemeasure")) + .Times(1) + .InSequence(seq) + .WillOnce(testing::Return(HttpResponse{200, this->livemeasure_response})); + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + + // Act + const types::powermeter::Powermeter& powermeter = controller.get_powermeter(); + + // Verify + EXPECT_EQ(powermeter.timestamp, "2023-09-10T21:10:08.068773"); + EXPECT_THAT(powermeter.energy_Wh_import.total, testing::FloatEq(1.0)); + EXPECT_THAT(powermeter.energy_Wh_export->total, testing::FloatEq(2.0)); + EXPECT_THAT(powermeter.power_W->total, testing::FloatEq(3.0)); + EXPECT_THAT(powermeter.current_A->DC.value(), testing::FloatEq(4.0)); + EXPECT_THAT(powermeter.voltage_V->DC.value(), testing::FloatEq(4.2)); + EXPECT_THAT(powermeter.meter_id.value(), ""); // not initialized +} + +/// \brief Test get_powermeter fails due to an invalid response status code +TEST_F(LemDCBM400600ControllerTest, test_get_powermeter_fail_invalid_response_code) { + + // Setup + testing::Sequence seq; + EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, get("/v1/livemeasure")) + .Times(1) + .InSequence(seq) + .WillOnce(testing::Return(HttpResponse{403, this->livemeasure_response})); + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + + // Act & Verify + EXPECT_THROW(controller.get_powermeter(), LemDCBM400600Controller::DCBMUnexpectedResponseException); +} + +/// \brief Test get_powermeter fails due to an invalid response status body +TEST_F(LemDCBM400600ControllerTest, test_get_powermeter_fail_invalid_response_body) { + + // Setup + testing::Sequence seq; + EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, get("/v1/livemeasure")) + .Times(1) + .InSequence(seq) + .WillOnce(testing::Return(HttpResponse{200, "invalid"})); + + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + // Act & Verify + EXPECT_THROW(controller.get_powermeter(), LemDCBM400600Controller::DCBMUnexpectedResponseException); +} + +/// \brief Test get_powermeter fails due to an http client error +TEST_F(LemDCBM400600ControllerTest, test_get_powermeter_fail_http_error) { + + // Setup + testing::Sequence seq; + EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, get("/v1/livemeasure")) + .Times(1) + .InSequence(seq) + .WillOnce(testing::Throw(HttpClientError("http client mock error"))); + + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + // Act & Verify + EXPECT_THROW(controller.get_powermeter(), HttpClientError); +} + +//**************************************************************** +// Test start_transaction behavior + +// \brief Test a successful start transaction +TEST_F(LemDCBM400600ControllerTest, test_start_transaction) { + // Setup + testing::Sequence seq; + EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, post("/v1/legal", this->expected_start_transaction_request_body)) + .Times(1) + .InSequence(seq) + .WillOnce(testing::Return(HttpResponse{201, R"({"running": true})"})); + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + + // Act + auto res = controller.start_transaction(this->transaction_request); + + // Verify + EXPECT_EQ(transaction_request_status_to_string(res.status), "OK"); + EXPECT_TRUE(res.error->empty()); + EXPECT_THAT(res.transaction_min_stop_time.value(), + testing::MatchesRegex("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z$")); + EXPECT_THAT(res.transaction_max_stop_time.value(), + testing::MatchesRegex("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z$")); + + auto delta = Everest::Date::from_rfc3339(res.transaction_max_stop_time.value()) - + Everest::Date::from_rfc3339(res.transaction_min_stop_time.value()); + EXPECT_EQ( + int(delta.count() / 1E9 / 60), + 48 * 60 - + 3); // delta of max and min stopping time should be 48 hours - 2 minutes wait time and 1 minute safety time +} + +// \brief Test a failed start transaction with the DCBM returning an invalid response +TEST_P(LemDCBM400600ControllerTestInvalidResponses, test_start_transaction_fail_invalid_response) { + + // Setup + // request fails due to an invalid response + testing::Sequence seq; + EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, post("/v1/legal", this->expected_start_transaction_request_body)) + .Times(1) + .InSequence(seq) + .WillRepeatedly(GetParam()); + EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, post("/v1/legal", this->expected_start_transaction_request_body)) + .Times(1) + .InSequence(seq) + .WillRepeatedly(GetParam()); + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + + // Act + auto res = controller.start_transaction(this->transaction_request); + + // Verify + EXPECT_EQ(transaction_request_status_to_string(res.status), "UNEXPECTED_ERROR"); + EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to start transaction mock_transaction_id.*")); + EXPECT_TRUE(res.transaction_min_stop_time->empty()); + EXPECT_TRUE(res.transaction_max_stop_time->empty()); +} + +// Setup parametrized invalid responses +static const std::string TEST_NAMES_START_TRANSACTION_INVALID_RESPONSES[2] = {"InvalidReturnCode", + "InvalidResponseBody"}; +INSTANTIATE_TEST_SUITE_P( + LemDCBM400600ControllerTestStartTransactionInvalidResponses, LemDCBM400600ControllerTestInvalidResponses, + testing::Values(testing::Return(HttpResponse{403, ""}), testing::Return(HttpResponse{201, "invalid"})), + [](const testing::TestParamInfo& info) { + return TEST_NAMES_START_TRANSACTION_INVALID_RESPONSES[info.index]; + }); + +// \brief Test a failed start transaction with the http request failing +TEST_F(LemDCBM400600ControllerTest, test_start_transaction_http_fail) { + // Setup + // request fails and throws an HttpClientError + testing::Sequence seq; + EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, post("/v1/legal", this->expected_start_transaction_request_body)) + .Times(1) + .InSequence(seq) + .WillRepeatedly(testing::Throw(HttpClientError{"mock error"})); + EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, post("/v1/legal", this->expected_start_transaction_request_body)) + .Times(1) + .InSequence(seq) + .WillRepeatedly(testing::Throw(HttpClientError{"mock error"})); + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + + // Act + auto res = controller.start_transaction(this->transaction_request); + + // Verify + EXPECT_EQ(transaction_request_status_to_string(res.status), "UNEXPECTED_ERROR"); + EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to start transaction mock_transaction_id.*")); + EXPECT_TRUE(res.transaction_min_stop_time->empty()); + EXPECT_TRUE(res.transaction_max_stop_time->empty()); +} + +//**************************************************************** +// Test stop_transaction behavior + +// \brief Test to stop a transaction and receive OCMF report. +TEST_F(LemDCBM400600ControllerTest, test_stop_transaction) { + + // Setup + EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(0); + + EXPECT_CALL(*this->http_client, put("/v1/legal?transactionId=mock_transaction_id", R"({"running": false})")) + .Times(1) + .WillOnce(testing::Return(HttpResponse{200, this->put_legal_response})); + + EXPECT_CALL(*this->http_client, get("/v1/ocmf?transactionId=mock_transaction_id")) + .Times(1) + .WillOnce(testing::Return(HttpResponse{200, "mock_ocmf_string"})); + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + + // Act + auto res = controller.stop_transaction("mock_transaction_id"); + + // Verify + EXPECT_EQ(transaction_request_status_to_string(res.status), "OK"); + EXPECT_EQ(res.ocmf, "mock_ocmf_string"); +} + +// \brief Test a failed stop transaction with the DCBM returning an invalid response +TEST_P(LemDCBM400600ControllerTestInvalidResponses, test_stop_transaction_fail_invalid_response) { + // Setup + // request fails repeatedly + EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(0); + + EXPECT_CALL(*this->http_client, put("/v1/legal?transactionId=mock_transaction_id", R"({"running": false})")) + .Times(2) + .WillRepeatedly(GetParam()); + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + + // Act + auto res = controller.stop_transaction("mock_transaction_id"); + + // Verify + EXPECT_EQ(transaction_request_status_to_string(res.status), "UNEXPECTED_ERROR"); + EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to stop transaction mock_transaction_id:.*")); + EXPECT_TRUE(res.ocmf->empty()); +} + +// Setup parametrized invalid responses +static const std::string TEST_NAMES_STOP_TRANSACTION_INVALID_RESPONSES[2] = {"InvalidReturnCode", + "InvalidResponseBody"}; +INSTANTIATE_TEST_SUITE_P( + LemDCBM400600ControllerTestStopTransactionInvalidResponses, LemDCBM400600ControllerTestInvalidResponses, + testing::Values(testing::Return(HttpResponse{403, ""}), testing::Return(HttpResponse{200, "invalid"})), + [](const testing::TestParamInfo& info) { + return TEST_NAMES_STOP_TRANSACTION_INVALID_RESPONSES[info.index]; + }); + +// \brief Test a failed stop transaction with the http request failing +TEST_F(LemDCBM400600ControllerTest, test_stop_transaction_http_fail) { + // Setup + // request fails repeatedly + EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(0); + EXPECT_CALL(*this->http_client, put("/v1/legal?transactionId=mock_transaction_id", R"({"running": false})")) + .Times(2) + .WillRepeatedly(testing::Throw(HttpClientError{"mock error"})); + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + + // Act + auto res = controller.stop_transaction("mock_transaction_id"); + + // Verify + EXPECT_EQ(transaction_request_status_to_string(res.status), "UNEXPECTED_ERROR"); + EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to stop transaction mock_transaction_id.*")); + EXPECT_TRUE(res.ocmf->empty()); +} + +//**************************************************************** +// Test init behavior + +// \brief Test the init method fetches the meter_id +TEST_F(LemDCBM400600ControllerTest, test_init_meter_id) { + // Setup + testing::Sequence seq; + EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, get("/v1/livemeasure")) + .Times(1) + .InSequence(seq) + .WillRepeatedly(testing::Return(HttpResponse{ + 200, + this->livemeasure_response, + })); + EXPECT_CALL(*this->http_client, get("/v1/status")) + .Times(1) + .InSequence(seq) + .WillRepeatedly(testing::Return(HttpResponse{ + 200, + R"({ "meterId": "mock_meter_id", "some_other_field": "other_value" })", + })); + EXPECT_CALL(*this->time_sync_helper, restart_unsafe_period()).Times(1).InSequence(seq); + EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, get("/v1/livemeasure")) + .Times(1) + .InSequence(seq) + .WillRepeatedly(testing::Return(HttpResponse{ + 200, + this->livemeasure_response, + })); + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + this->controller_config); + + // Assert: no meter id set before init call + EXPECT_EQ(controller.get_powermeter().meter_id, ""); + + // Act: initialize + struct ntp_server_spec ntp_spec; + controller.init(); + + // verify by calling the powermeter interface that should provide the mocked metric id + EXPECT_EQ(controller.get_powermeter().meter_id, "mock_meter_id"); +} + +// \brief Test the init method retries to fetch the meter id in case of a HttpClientError +TEST_F(LemDCBM400600ControllerTest, test_init_meter_id_retry_success) { + + // Setup + int number_of_retries = 3; + testing::Sequence seq; + EXPECT_CALL(*this->http_client, get("/v1/status")) + .Times(number_of_retries - 1) + .InSequence(seq) + .WillRepeatedly(testing::Throw(HttpClientError{"mock error"})); + + EXPECT_CALL(*this->http_client, get("/v1/status")) + .Times(1) + .InSequence(seq) + .WillOnce(testing::Return(HttpResponse{ + 200, + R"({ "meterId": "mock_meter_id", "some_other_field": "other_value" })", + })); + EXPECT_CALL(*this->time_sync_helper, restart_unsafe_period()).Times(1).InSequence(seq); + EXPECT_CALL(*this->http_client, get("/v1/livemeasure")) + .Times(1) + .InSequence(seq) + .WillRepeatedly(testing::Return(HttpResponse{ + 200, + this->livemeasure_response, + })); + const LemDCBM400600Controller::Conf controller_config{number_of_retries, 1, 1, 0}; + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + controller_config); + + // Act + struct ntp_server_spec ntp_spec; + controller.init(); + + // Verify + EXPECT_EQ(controller.get_powermeter().meter_id, "mock_meter_id"); +} + +// \brief Test at init the HttpClientError is re-raised after the provided number of attempts all failed +TEST_F(LemDCBM400600ControllerTest, test_init_meter_id_retry_fail_eventually) { + // Setup + int number_of_retries = 3; + EXPECT_CALL(*this->http_client, get("/v1/status")) + .Times(1 + number_of_retries) + .WillRepeatedly(testing::Throw(HttpClientError{"mock error"})); + EXPECT_CALL(*this->time_sync_helper, restart_unsafe_period()).Times(0); + + const LemDCBM400600Controller::Conf controller_config{number_of_retries, 1, 1, 0}; + LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), + controller_config); + + // Act & Verify + struct ntp_server_spec ntp_spec; + EXPECT_THROW(controller.init(), HttpClientError); +} + +} // namespace module::main diff --git a/modules/LemDCBM400600/tests/test_lem_dcbm_400_600_e2e.py b/modules/LemDCBM400600/tests/test_lem_dcbm_400_600_e2e.py new file mode 100644 index 000000000..eb7e00ec9 --- /dev/null +++ b/modules/LemDCBM400600/tests/test_lem_dcbm_400_600_e2e.py @@ -0,0 +1,196 @@ +import asyncio +import logging +import re +from datetime import timedelta, datetime, timezone +from pathlib import Path + +import pytest + +from lem_dcbm_test_utils.dcbm import DCBMInterface +from lem_dcbm_test_utils.everest import LemDCBMStandaloneEverestInstance, LecmDDCBMModuleConfig, \ + StartTransactionSuccessResponse, StopTransactionSuccessResponse +from lem_dcbm_test_utils.types import Powermeter + + +@pytest.fixture() +def dcbm_host(request) -> str: + host = request.config.getoption("--lem-dcbm-host") + assert host + return host + + +@pytest.fixture() +def dcbm_port(request) -> int: + port = int(request.config.getoption("--lem-dcbm-port")) + assert port + return port + + +@pytest.fixture(scope="function") +def everest_test_instance(request, dcbm_host, dcbm_port, dcbm) -> LemDCBMStandaloneEverestInstance: + """Fixture that can be used to start and stop everest-core""" + everest_prefix = Path(request.config.getoption("--everest-prefix")) + try: + dcbm.reset_device() + with LemDCBMStandaloneEverestInstance(everest_prefix=everest_prefix, + config=LecmDDCBMModuleConfig(ip_address=dcbm_host, + port=dcbm_port)) as everest: + yield everest + finally: + dcbm.reset_device() + + +@pytest.fixture(scope="function") +def everest_test_instance_ntp_configured(request, dcbm_host, dcbm_port, dcbm) -> LemDCBMStandaloneEverestInstance: + """Fixture that can be used to start and stop everest-core""" + everest_prefix = Path(request.config.getoption("--everest-prefix")) + try: + dcbm.reset_device() + with LemDCBMStandaloneEverestInstance(everest_prefix=everest_prefix, + config=LecmDDCBMModuleConfig(ip_address=dcbm_host, + port=dcbm_port, + ntp_server_1_ip_addr="test_ntp_1", + ntp_server_1_port=124, + ntp_server_2_ip_addr="test_ntp_2", + ntp_server_2_port=125 + )) as everest: + yield everest + finally: + dcbm.reset_device() + + +@pytest.fixture(scope="function") +def everest_test_instance_tls(request, dcbm_host, dcbm_port, dcbm) -> LemDCBMStandaloneEverestInstance: + """Fixture that can be used to start and stop everest-core""" + everest_prefix = Path(request.config.getoption("--everest-prefix")) + try: + dcbm.reset_device() + certificate = dcbm.get_certificate() + certificate = certificate.replace("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n").replace( + "-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----") + dcbm.activate_tls_via_http() + with LemDCBMStandaloneEverestInstance(everest_prefix=everest_prefix, + config=LecmDDCBMModuleConfig(ip_address=dcbm_host, port=dcbm_port, + meter_tls_certificate=certificate)) as everest: + yield everest + finally: + dcbm.reset_device() + + +@pytest.fixture() +def dcbm(dcbm_host, dcbm_port): + dcbm = DCBMInterface(host=dcbm_host, port=dcbm_port) + return dcbm + + +@pytest.mark.asyncio +async def test_lem_dcbm_e2e_powermeter_does_regular_publish(everest_test_instance, dcbm): + for i in range(2): + logging.info(f"waiting for {i + 1}th powermeter publications") + await everest_test_instance.probe_module.poll_next_powermeter(1.25) + + +@pytest.mark.asyncio +async def test_lem_dcbm_e2e_powermeter_meterid_correct(everest_test_instance): + power_meter: Powermeter = await everest_test_instance.probe_module.poll_next_powermeter(1.25) + assert re.match(r"^\d+$", power_meter.meter_id), f"got unexpected meter_id {power_meter.meter_id}" + + +@pytest.mark.asyncio +async def test_lem_dcbm_e2e_start_stop_transaction(everest_test_instance, dcbm): + assert await dcbm.wait_for_status(17), "device has invalid status before transaction start" + + start_result = everest_test_instance.probe_module.call_powermeter_command('start_transaction', + {"value": { + "evse_id": "mock_evse_id", + "transaction_id": "e2e_test_transaction", + "client_id": "mock_client_id", + "tariff_id": 1, + "cable_id": 0, + "user_data": "mock_user_data" + }}) + + parsed_start_result = StartTransactionSuccessResponse(**start_result) + assert 48 * 60 - 3.1 < (( + parsed_start_result.transaction_max_stop_time - parsed_start_result.transaction_min_stop_time).total_seconds() / 60) <= 48 * 60 - 2.9 + + logging.info("started transaction 'e2e_test_transaction'") + + assert await dcbm.wait_for_status(21), "device has invalid status after transaction start" + + stop_result = everest_test_instance.probe_module.call_powermeter_command('stop_transaction', + {"transaction_id": "e2e_test_transaction"} + ) + + StopTransactionSuccessResponse(**stop_result) + + logging.info("stopped transaction 'e2e_test_transaction'") + + assert await dcbm.wait_for_status(17), "device has invalid status after transaction stop" + + +@pytest.mark.asyncio +async def test_lem_dcbm_e2e_time_sync(everest_test_instance, dcbm): + """ Check time gets synced per default + + :param everest_test_instance: + :param dcbm: + :return: + """ + + # start transaction to enforce early sync; tidied up by fixture + assert everest_test_instance.probe_module.call_powermeter_command('start_transaction', + {"value": { + "evse_id": "mock_evse_id", + "transaction_id": "e2e_test_transaction", + "client_id": "mock_client_id", + "tariff_id": 1, + "cable_id": 0, + "user_data": "mock_user_data" + }})["status"] == "OK" + + dcbm.set_time(datetime.now() - timedelta(days=365)) + + async def check_time(): + while ((dcbm.get_status().time.astimezone(timezone.utc) - datetime.now(timezone.utc)).total_seconds() > 60): + await asyncio.sleep(0.25) + + await asyncio.wait_for(check_time(), 2) + + +@pytest.mark.asyncio +async def test_lem_dcbm_e2e_ntp_setup(everest_test_instance_ntp_configured, dcbm): + """ Test ntp is setup correctly and activated if configured. """ + + # start transaction to enforce early sync; tidied up by fixture + assert everest_test_instance_ntp_configured.probe_module.call_powermeter_command('start_transaction', + {"value": { + "evse_id": "mock_evse_id", + "transaction_id": "e2e_test_transaction", + "client_id": "mock_client_id", + "tariff_id": 1, + "cable_id": 0, + "user_data": "mock_user_data" + }})["status"] == "OK" + + async def check(): + while not (ntp_settings := dcbm.get_ntp_settings()).ntpActivated: + await asyncio.sleep(0.25) + return ntp_settings + + ntp_settings = await asyncio.wait_for(check(), timeout=2) + assert ntp_settings.ntpActivated is True + assert ntp_settings.servers == [{ + "ipAddress": "test_ntp_1", + "port": 124 + }, + { + "ipAddress": "test_ntp_2", + "port": 125 + }] + + +@pytest.mark.asyncio +async def test_lem_dcbm_2e_get_powermeter_tls(everest_test_instance_tls): + power_meter: Powermeter = await everest_test_instance_tls.probe_module.poll_next_powermeter(1.25) + assert re.match(r"^\d+$", power_meter.meter_id), f"got unexpected meter_id {power_meter.meter_id}" diff --git a/modules/LemDCBM400600/tests/test_lem_dcbm_400_600_sil.py b/modules/LemDCBM400600/tests/test_lem_dcbm_400_600_sil.py new file mode 100644 index 000000000..689e7e011 --- /dev/null +++ b/modules/LemDCBM400600/tests/test_lem_dcbm_400_600_sil.py @@ -0,0 +1,48 @@ +import logging +from pathlib import Path + +import pytest + +from lem_dcbm_test_utils.everest import LemDCBMStandaloneEverestInstance, LecmDDCBMModuleConfig, \ + StartTransactionSuccessResponse, StopTransactionSuccessResponse + + +@pytest.fixture(scope="function") +def everest_test_instance(request, lem_dcbm_mock) -> LemDCBMStandaloneEverestInstance: + """Fixture that can be used to start and stop everest-core""" + everest_prefix = Path(request.config.getoption("--everest-prefix")) + with LemDCBMStandaloneEverestInstance(everest_prefix=everest_prefix, + config=LecmDDCBMModuleConfig(ip_address="localhost", + port=8000)) as everest: + yield everest + + +@pytest.mark.asyncio +async def test_get_powermeter(everest_test_instance): + for i in range(2): + logging.info(f"waiting for {i + 1}th powermeter publications") + await everest_test_instance.probe_module.poll_next_powermeter(1.25) + + +def test_start_transaction(everest_test_instance): + res = everest_test_instance.probe_module.call_powermeter_command('start_transaction', + {"value": { + "evse_id": "mock_evse_id", + "transaction_id": "mock_transaction_id", + "client_id": "mock_client_id", + "tariff_id": 42, + "cable_id": 43, + "user_data": "mock_user_data" + } + }) + + parsed_start_result = StartTransactionSuccessResponse(**res) + assert 48 * 60 - 3.1 < (( + parsed_start_result.transaction_max_stop_time - parsed_start_result.transaction_min_stop_time).total_seconds() / 60) <= 48 * 60 - 2.9 + + +def test_stop_transaction(everest_test_instance): + res = everest_test_instance.probe_module.call_powermeter_command('stop_transaction', + {"transaction_id": "mock_transaction_id"} + ) + StopTransactionSuccessResponse(**res) diff --git a/modules/LemDCBM400600/tests/test_lem_dcbm_time_sync_helper.cpp b/modules/LemDCBM400600/tests/test_lem_dcbm_time_sync_helper.cpp new file mode 100644 index 000000000..0b2a2d1c8 --- /dev/null +++ b/modules/LemDCBM400600/tests/test_lem_dcbm_time_sync_helper.cpp @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +// Unit tests for the LemDCBMTimeSyncHelper + +#include "http_client_interface.hpp" +#include "lem_dcbm_400600_controller.hpp" +#include "lem_dcbm_time_sync_helper.hpp" +#include +#include + +#include + +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)); + MOCK_METHOD(HttpResponse, put, (const std::string& path, const std::string& body), (override, const)); +}; + +// Fixture class providing +// - a http client mock +// - a mock of the time sync helper +// - default responses & request objects +class LemDCBMTimeSyncHelperTest : public ::testing::Test { + +protected: + std::unique_ptr http_client; + + const std::string put_settings_response_success{R"({ + "meterId": "mock_meter_id", + "result": 1 + })"}; + + const std::string put_settings_response_fail{R"({ + "meterId": "mock_meter_id", + "result": 0 + })"}; + + const std::string expected_system_sync_request_regex{ + R"(\{"time":\{"utc":"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z"\}\})"}; + + const std::string expected_ntp_sync_request{ + R"({"ntp":{"servers":[{"ipAddress":"123.123.123.123","port":123},{"ipAddress":"213.213.213.213","port":213}],"syncPeriod":120,"ntpActivated":true}})"}; + + const struct ntp_server_spec spec_ntp_disabled {}; + + const struct ntp_server_spec spec_ntp_enabled { + "123.123.123.123", 123, "213.213.213.213", 213 + }; + + void SetUp() override { + this->http_client = std::make_unique(); + } +}; + +// Extended fixture for parametrizing tests for invalid response checks +class LemDCBMTimeSyncHelperTestInvalidResponses + : public LemDCBMTimeSyncHelperTest, + public ::testing::WithParamInterface> {}; + +//**************************************************************** + +/// \brief sync() sends correct HTTP request when in system time mode +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_success_system_time) { + std::string input_to_put; + // Setup + EXPECT_CALL(*this->http_client, + put("/v1/settings", testing::ContainsRegex(this->expected_system_sync_request_regex))) + .Times(1) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success})); + LemDCBMTimeSyncHelper helper(spec_ntp_disabled); + helper.restart_unsafe_period(); + + // Act + helper.sync(*this->http_client); +} + +/// \brief sync() sends correct HTTP request when in NTP mode +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_success_ntp) { + // Setup + EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request)) + .Times(1) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success})); + LemDCBMTimeSyncHelper helper(spec_ntp_enabled); + helper.restart_unsafe_period(); + + // Act + helper.sync(*this->http_client); +} + +/// \brief sync() throws an exception if it gets a status code other than 200, system time mode +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_if_status_code_not_200_sys_time) { + // Setup + EXPECT_CALL(*this->http_client, + put("/v1/settings", testing::ContainsRegex(this->expected_system_sync_request_regex))) + .Times(1) + .WillOnce(testing::Return(HttpResponse{400, ""})); + LemDCBMTimeSyncHelper helper(spec_ntp_disabled); + helper.restart_unsafe_period(); + + // Act + EXPECT_THROW(helper.sync(*this->http_client), LemDCBM400600Controller::UnexpectedDCBMResponseCode); +} + +/// \brief sync() throws an exception if it gets a status code other than 200, NTP mode +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_if_status_code_not_200_ntp) { + // Setup + EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request)) + .Times(1) + .WillOnce(testing::Return(HttpResponse{400, ""})); + LemDCBMTimeSyncHelper helper(spec_ntp_enabled); + helper.restart_unsafe_period(); + + // Act + EXPECT_THROW(helper.sync(*this->http_client), LemDCBM400600Controller::UnexpectedDCBMResponseCode); +} + +/// \brief sync() throws exception if it gets a response of 200, but a failed write (result=0), system time mode +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_if_200_but_result_is_0_sys_time) { + // Setup + EXPECT_CALL(*this->http_client, + put("/v1/settings", testing::ContainsRegex(this->expected_system_sync_request_regex))) + .Times(1) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_fail})); + LemDCBMTimeSyncHelper helper(spec_ntp_disabled); + helper.restart_unsafe_period(); + + // Act + EXPECT_THROW(helper.sync(*this->http_client), LemDCBM400600Controller::UnexpectedDCBMResponseBody); +} + +/// \brief sync() throws exception if it gets a response of 200, but a failed write (result=0), NTP mode +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_if_200_but_result_is_0_ntp) { + // Setup + EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request)) + .Times(1) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_fail})); + LemDCBMTimeSyncHelper helper(spec_ntp_enabled); + helper.restart_unsafe_period(); + + // Act + EXPECT_THROW(helper.sync(*this->http_client), LemDCBM400600Controller::UnexpectedDCBMResponseBody); +} + +/// \brief sync_if_deadline_expired() called twice will not send anything the second time if the first call succeeds +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_if_deadline_expired_twice_when_first_succeeds) { + // Setup + EXPECT_CALL(*this->http_client, + put("/v1/settings", testing::ContainsRegex(this->expected_system_sync_request_regex))) + .Times(1) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success})); + + // override the timing constants so that we don't need to wait for the time barriers to pass + // We do set deadline_increment_after_sync though, as we want to ensure it has not passed + struct timing_config timing_constants { + std::chrono::seconds(0), std::chrono::seconds(0), std::chrono::seconds(10000000), + }; + + LemDCBMTimeSyncHelper helper(spec_ntp_disabled, timing_constants); + helper.restart_unsafe_period(); + + // Act + helper.sync_if_deadline_expired(*this->http_client); + helper.sync_if_deadline_expired(*this->http_client); +} + +/// \brief sync_if_deadline_expired() called twice will not send anything the second time, even if the first call fails +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_if_deadline_expired_twice_when_first_fails) { + // Setup + EXPECT_CALL(*this->http_client, + put("/v1/settings", testing::ContainsRegex(this->expected_system_sync_request_regex))) + .Times(1) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_fail})); + + // override the timing constants so that we don't need to wait for the time barriers to pass + // We do set min_time_between_sync_retries though, as we want to ensure it has not passed + struct timing_config timing_constants { + std::chrono::seconds(0), std::chrono::seconds(1000000), std::chrono::seconds(0), + }; + + LemDCBMTimeSyncHelper helper(spec_ntp_disabled, timing_constants); + helper.restart_unsafe_period(); + + // Act + helper.sync_if_deadline_expired(*this->http_client); + helper.sync_if_deadline_expired(*this->http_client); +} + +/// \brief sync() in NTP mode will not send the sync twice if the first sync succeeded +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_twice_if_first_succeeds) { + // Setup + EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request)) + .Times(1) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success})); + + // override the timing constants so that we don't need to wait for the time barriers to pass + struct timing_config timing_constants { + std::chrono::seconds(0), std::chrono::seconds(0), std::chrono::seconds(0), + }; + + LemDCBMTimeSyncHelper helper(spec_ntp_enabled, timing_constants); + helper.restart_unsafe_period(); + + // Act + helper.sync(*this->http_client); + helper.sync(*this->http_client); +} + +/// \brief sync() in NTP mode will send the sync twice, even if the first sync succeeded, if it's not safe to save +/// settings yet +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_twice_if_first_succeeds_before_safe) { + // Setup + EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request)) + .Times(2) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success})) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success})); + + // override the timing constants so that we don't need to wait for the time barriers to pass + // we do set min_time_before_setting_write_is_safe as we want to ensure it hasn't passed yet though + struct timing_config timing_constants { + std::chrono::seconds(1000000), std::chrono::seconds(0), std::chrono::seconds(0), + }; + + LemDCBMTimeSyncHelper helper(spec_ntp_enabled, timing_constants); + helper.restart_unsafe_period(); + + // Act + helper.sync(*this->http_client); + helper.sync(*this->http_client); +} + +/// \brief sync() in NTP mode will send the sync twice if the first sync failed +TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_twice_if_first_fails) { + // Setup + EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request)) + .Times(2) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_fail})) + .WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success})); + LemDCBMTimeSyncHelper helper(spec_ntp_enabled); + helper.restart_unsafe_period(); + + // Act + EXPECT_THROW(helper.sync(*this->http_client), LemDCBM400600Controller::UnexpectedDCBMResponseBody); + helper.sync(*this->http_client); +} + +} // namespace module::main \ No newline at end of file diff --git a/modules/LemDCBM400600/utils/lem_dcbm_api_mock/certificate.pem b/modules/LemDCBM400600/utils/lem_dcbm_api_mock/certificate.pem new file mode 100644 index 000000000..08ab6e3aa --- /dev/null +++ b/modules/LemDCBM400600/utils/lem_dcbm_api_mock/certificate.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIUHDu1ZdpL229xmwqrmq/oq9YQaYwwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA5MTQxMjAxMDhaFw0yNDA5 +MTMxMjAxMDhaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDCES0SIQSMzKi6aIuLNkjXUj1/eGjuAV2qLcPiaRe3 +GYRy+tDS4wJb0JxdU2JYMzGrq3tNGcm6E/bXpkJWjB1znFhd+6wr077KV+ryMfBa +QwE7uIOnj4XeIBRhU9QvgSF3vfvoxOEKsq6X+cXPlAelbnMrIXniL4lwLNJD2UAl +eNYmJFKIJfZPmnNKLQwZkvIL8H5G134KMvOh2AVG1EHuzUBoKs72d77TI6UsITu9 +/PeATVxm9hhRpk1tuq/NLoUHTqgUPsfN83zeCm7buOOmQpJsypFz5lVmLmtq7YY3 ++vu4+IuUQegJUC+eXH+WXrh1gkCpNre/S7KgS0FWnJD3AgMBAAGjUzBRMB0GA1Ud +DgQWBBQ/YFwElfxomN+kQvtf4tTjU4XGrzAfBgNVHSMEGDAWgBQ/YFwElfxomN+k +Qvtf4tTjU4XGrzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAZ +K9/4szwsoeTQbPxeDNKNeBRrdHhVOtC3PLP2O0eqZkogTFE4PhreL7S+Q4INbrUh +Pw/mZ9FwfsyHVupJWUBgPZx9kSflAJHFG7rikY13UenLmYNU4lGsoJQEewLw+wT1 +jfJgW/LXZ2He1dMsp3IVyNjR62BtZyI4B9ArUxyILpSSsczk7XN4oEkWDCTATP7t +VfsKaM6eIfSnY11g1koVjGy+YtdcO5GJ/6Q7va1BuT3PzD3GjcxPZfhVu3rJBupl +0p0LoiBSxpcepMYag5zguxoyU78FKdShFyl5lnFUtAWVD9Hi1+M/znYwiXpS6EGc +DW+bAzWAH3M1KKV2UUTa +-----END CERTIFICATE----- diff --git a/modules/LemDCBM400600/utils/lem_dcbm_api_mock/key.pem b/modules/LemDCBM400600/utils/lem_dcbm_api_mock/key.pem new file mode 100644 index 000000000..c4ca2b2e6 --- /dev/null +++ b/modules/LemDCBM400600/utils/lem_dcbm_api_mock/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDCES0SIQSMzKi6 +aIuLNkjXUj1/eGjuAV2qLcPiaRe3GYRy+tDS4wJb0JxdU2JYMzGrq3tNGcm6E/bX +pkJWjB1znFhd+6wr077KV+ryMfBaQwE7uIOnj4XeIBRhU9QvgSF3vfvoxOEKsq6X ++cXPlAelbnMrIXniL4lwLNJD2UAleNYmJFKIJfZPmnNKLQwZkvIL8H5G134KMvOh +2AVG1EHuzUBoKs72d77TI6UsITu9/PeATVxm9hhRpk1tuq/NLoUHTqgUPsfN83ze +Cm7buOOmQpJsypFz5lVmLmtq7YY3+vu4+IuUQegJUC+eXH+WXrh1gkCpNre/S7Kg +S0FWnJD3AgMBAAECggEAXKmv4Bqk3gfwvsUhcDDU2f86Pw3C6HX9f78Ha6mrebF0 +9SO+pxteqnFy3/rrF9sl6ebg4oEgObnDoNeRkFqpy2SJKyL65BhgXqRZGhjvP2IE +CjyBnHXiwRuHL6vDwoUBcnfj+xJas+16gTVxlrqDZiHVWvBKjs4M7WNxaJUo1FrG +qEO7G93DGPox4YinPCye7pMvgH/LJKbR0d55gH0nooa+OMo9niVgf5hK7bTjD6Zp +67YncxTqUERSzCOVneWNXcD3cYj6Ktb1pWynLLt0AEVcMo1W2qGx8o6/ekqih7rG +sRs3KHA+irhfvZjrSc++PhdLOqndxOtWe2UgOo/pJQKBgQDIDxnDlMgIDgPYe2sp +xkbAZkKpRJPqmmrZDofnlZXrHZRXVs10YNP3ZRbRYXJF9gImsVip67/x3HFzB1EW +nF/LfD00S9qJKwTPLlquOGPs+f6sI1A2DALCcugUovzs8IWs5emZ+p/YMdKUaoDa +fPsoNvnhJAq2tolRYXLBSaRbVQKBgQD4VSfLNc4Kq6aj8JGR2f4o6I141E9AwdDQ +BYcfA4zNBnwhcSR+Ucu6qOajiGMb4h83Q8DqP5YZt/52SXL2ofwEGvV2warknqT/ +XoFZihv1E2gf7iA8wJgBs237s/k29E4pvt9xhOQoz2FHwgUS5I575Uytwy6gZj7P +z+ucFcEzGwKBgQCM9qbuuozfsBBhn0T5IG6F7wgVgO7ApaGX47c7JJmIG0WE7PLD +h96TOTWEvybnyNnnLZsNz8FlyDBgHs2yIukU1ivCB5iqghdXbJAUpkMynUwnOpKw +InJnVNPWhqm0wh1OoImjJ4ctrJ12Wj0Etn+57FLRorWx3JiRMRrMuvkGKQKBgQDp +jgVIHIl1Ba1LQGVYXEKqrNTpUBxdlitSifBkHc2dwjyaozOkTj+ihVmtcgdsYQXk +zycv6K+97os3KqsiBITmQ4iasViNfhtGRda3pKnYm/DxHt9Y4/XSm7OT59c6dRjS +MD8sH8UKEMi4WWS2ORk8sxKj1g8TMjZe8njwKpGzAwKBgQC42RO1590K8eT7GIgD +ooDiYjZ+RXiTTLXt6OPhlS5OKBcaCHDrWAe/tiGNPKaVX3vmlbkEyx0mU+rEiuSW +kos+MJvFg9+NBrI5sCMEpZ+hwnZ55X9ISdxSi1hmVRNNPpz1NZPmAggCLr1sgIDW +Qdx4UKwwzCYIHUr4wm4rK/p/2w== +-----END PRIVATE KEY----- diff --git a/modules/LemDCBM400600/utils/lem_dcbm_api_mock/main.py b/modules/LemDCBM400600/utils/lem_dcbm_api_mock/main.py new file mode 100644 index 000000000..a79ddf0ff --- /dev/null +++ b/modules/LemDCBM400600/utils/lem_dcbm_api_mock/main.py @@ -0,0 +1,383 @@ +from datetime import datetime, timezone +from pathlib import Path +from multiprocessing import Process + +import uvicorn +from fastapi import FastAPI, APIRouter +from fastapi.responses import PlainTextResponse +from pydantic import BaseModel, Field + +app = FastAPI() + +v1_api = APIRouter() + +class UTCTimeSetting(BaseModel): + utc: str + +class TimeSetting(BaseModel): + time: UTCTimeSetting + +class LEMSyncTimePutSettingsResponse(BaseModel): + meterId: str + result: int + +@v1_api.put("/settings") +def put_settings(body: TimeSetting) -> LEMSyncTimePutSettingsResponse: + return LEMSyncTimePutSettingsResponse(**{"meterId":"mock_meter_id","result":1}) + + +class LEMLiveMeasure(BaseModel): + voltage: float + current: float + power: float + temperatureH: float + temperatureL: float + energyImportTotal: float + energyExportTotal: float + timestamp: datetime # in ISO 8601 format + + +@v1_api.get("/livemeasure") +def livemeasure() -> LEMLiveMeasure: + return LEMLiveMeasure(**{"voltage": 0, + "current": 0, + "power": 0, + "temperatureH": 0, + "temperatureL": 0, + "energyImportTotal": 0, + "energyExportTotal": 0, + "timestamp": datetime.now(timezone.utc)} + ) + + +class LEMStartTransactionRequest(BaseModel): + evseId: str + transactionId: str + clientId: str + tariffId: int + cableId: int + userData: str + + +class LEMStartTransactionPostResponse(BaseModel): + evseId: str + transactionId: str + clientId: str + tariffId: int + cableId: int + running: bool + + +@v1_api.post("/legal", status_code=201) +def start_transaction(body: LEMStartTransactionRequest) -> LEMStartTransactionPostResponse: + return LEMStartTransactionPostResponse(** + {"evseId": "evse458877", + "transactionId": body.transactionId, + "clientId": "client12", + "tariffId": 2, "cableId": 2, + "running": True} + ) + + +class LEMPutTransactionRequestCableSP(BaseModel): + cableSpName: str + cableSpId: int + cableSpRes: int + + +class LEMPutTransactionRequestValue(BaseModel): + energyUnit: str + energyImport: float + energyImportTotalStart: float + energyImportTotalStop: float + energyExport: float + energyExportTotalStart: float + energyExportTotalStop: float + + +class LEMPutTransactionRequestMeterValue(BaseModel): + timestampStart: datetime + timestampStop: datetime + transactionDuration: int + intermediateRead: bool + transactionStatus: int # note: error in first description p. 45 + sampleValue: LEMPutTransactionRequestValue + + +class LEMPutTransactionRequestResponse(BaseModel): + paginationCounter: int + transactionId: str + evseId: str + clientId: str + tariffId: int + cableSp: LEMPutTransactionRequestCableSP + userData: str + meterValue: LEMPutTransactionRequestMeterValue + + meterId: str + signature: str + publicKey: str + + +class LEMPutTransactionRequestBody(BaseModel): + running: bool + + +@v1_api.put("/legal") +def put_transaction(transactionId: str, body: LEMPutTransactionRequestBody) -> LEMPutTransactionRequestResponse: + return LEMPutTransactionRequestResponse( + **{ + "paginationCounter": 6, + "transactionId": transactionId, + "evseId": "+49*DEF*E123ABC", + "clientId": "C12", + "tariffId": 2, + "cableSp": { + "cableSpName": "2mR_Comp", + "cableSpId": 1, + "cableSpRes": 2 + }, + "userData": "", + "meterValue": { + "timestampStart": "2020-12-10T16:39:15+01:00", + "timestampStop": "2020-12-10T16:39:15+01:00", + "transactionDuration": 70, + "intermediateRead": False, + "transactionStatus": 25, + "sampleValue": { + "energyUnit": "kWh", + "energyImport": 7.637, + "energyImportTotalStart": 188.977, + "energyImportTotalStop": 196.614, + "energyExport": 0.000, + "energyExportTotalStart": 0.000, + "energyExportTotalStop": 0.000 + }}, + "meterId": "12024072805", + "signature": "304502203DC38FBC722D216568D6ECB4B3" + "52577A999B6D184EA6AD48BDCAE7766DB1D628022100A768" + "7B4CB5573829D407DD4B17D41C297917B7E8307E5017711B" + "5A3A987F6801", + "publicKey": "A80F10D968E1122F8820F288B23C4E1C0D" + "A912F35B48481274ADFEFE66D7E87E130C7CF2B8047C45CF" + "105041C8C3A57DD242782F755C9443F42DABA9404A67BF" + } + ) + + +class OCMFReading(BaseModel): + TM: str # actually datetime + status + TX: str + RV: float + RI: str + RU: str + RT: str + EF: str + ST: str + UC: dict # not in standard? LEM : "This field reflects the /settings/cableConf selected table for the transaction by the /legal/ cableId input parameter. This is a LEM specific field, using specific IDs:" + + +class OCMFPart1(BaseModel): + FV: str + GI: str + GS: str + GV: str + PG: str + MV: str + MM: str + MS: str + MF: str + IS: bool + IL: str + IF: list[str] + IT: str + ID: str + TT: str + RD: list[OCMFReading] + + +OCMF_EXAMPLE_JSON1 = { + "FV": "1.0", + "GI": "ABL SBC-301", + "GS": "808829900001", + "GV": "1.4p3", + "PG": "T12345", + "MV": "Phoenix Contact", + "MM": "EEM-350-D-MCB", + "MS": "BQ27400330016", + "MF": "1.0", + "IS": True, + "IL": "VERIFIED", + "IF": [ + "RFID_PLAIN", + "OCPP_RS_TLS" + ], + "IT": "ISO14443", + "ID": "1F2D3A4F5506C7", + "TT": "Tarif 1", + "RD": [ + { + "TM": "2018-07-24T13:22:04,000+0200 S", + "TX": "B", + "RV": 2935.6, + "RI": "1-b:1.8.0", + "RU": "kWh", + "RT": "AC", + "EF": "", + "ST": "G", + # LEM Special + "UC": {"UN": "cableName", "UI": 1, "UR": 1} + } + ] +} + + +class OCMFPart2(BaseModel): + SA: str = Field( + required=False) # NOTE: could not find this in https://github.com/SAFE-eV/OCMF-Open-Charge-Metering-Format/blob/master/OCMF-en.md, but specified in LEM + SD: str + + +OCMF_EXAMPLE_JSON2 = { + "SA": "ECDSA-secp256r1-SHA256", # LEM special + "SD": "887FABF407AC82782EEFFF2220C2F856AEB0BC22364BBCC6B55761911ED651D1A922BADA88818C9671AFEE7094D7F536" +} + + +@v1_api.get("/ocmf", response_class=PlainTextResponse) +def get_last_transaction_ocmf_by_transaction_id(transactionId: str | None) -> str: + return f"OCMF|{OCMFPart1(**OCMF_EXAMPLE_JSON1)}|{OCMFPart2(**OCMF_EXAMPLE_JSON2)}" + + +@v1_api.get("/ocmf/{transactionIndex}", response_class=PlainTextResponse) +def get_last_transaction_ocmf_by_transaction_index(transactionIndex: int) -> str: + return f"OCMF|{OCMFPart1(**OCMF_EXAMPLE_JSON1)}|{OCMFPart2(**OCMF_EXAMPLE_JSON2)}" + + +class LemDCBMStatusBits(BaseModel): + suLinkStatusIsOk: bool + muFatalErrorOccured: bool + transactionIsOnGoing: bool + tamperingIsDetected: bool + timeSyncStatusIsOk: bool + overTemperatureIsDetected: bool + reversedVoltage: bool + suMeasureFailureOccurred: bool + + +class LemDCBMStatusVersion(BaseModel): + applicationFirmwareVersion: str + applicationFirmwareAuthTag: str + legalFirmwareVersion: str + legalFirmwareAuthTag: str + sensorFirmwareVersion: str + sensorFirmwareCrc: str + + +class LemDCBMStatusErrorsBits(BaseModel): + muInitIsFailed: bool + suStateIsInvalid: bool + versionCheckIsFailed: bool + muRngInitIsFailed: bool + muDataIntegrityIsFailed: bool + muFwIntegrityIsFailed: bool + suIntegrityIsFailed: bool + logbookIntegrityIsFailed: bool + logbookIsFull: bool + memoryAccessIsFailed: bool + muStateIsFailed: bool + + +class LemDCBMStatusErrors(BaseModel): + value: int + bits: LemDCBMStatusErrorsBits + + +class LemDCBMStatus(BaseModel): + value: int + bits: LemDCBMStatusBits + + +class LemDCBMStatusResponse(BaseModel): + status: LemDCBMStatus + version: LemDCBMStatusVersion + time: str + ipAddress: str + meterId: str + errors: LemDCBMStatusErrors + publicKey: str + publicKeyOcmf: str + indexOfLastTransaction: int + numberOfStoredTransactions: int + + +@v1_api.get("/status") +def get_status() -> LemDCBMStatusResponse: + return LemDCBMStatusResponse(**{"status": { + "value": 17, + "bits": { + "suLinkStatusIsOk": True, + "muFatalErrorOccured": True, + "transactionIsOnGoing": True, + "tamperingIsDetected": True, + "timeSyncStatusIsOk": True, + "overTemperatureIsDetected": True, + "reversedVoltage": True, + "suMeasureFailureOccurred": True + }}, + "version": { + "applicationFirmwareVersion": "string", + "applicationFirmwareAuthTag": "string", + "legalFirmwareVersion": "string", + "legalFirmwareAuthTag": "string", + "sensorFirmwareVersion": "string", + "sensorFirmwareCrc": "string" + }, + "time": "string", + "ipAddress": "string", + "meterId": "mock_meter_id", + "errors": { + "value": 0, + "bits": { + "muInitIsFailed": False, + "suStateIsInvalid": False, + "versionCheckIsFailed": False, + "muRngInitIsFailed": False, + "muDataIntegrityIsFailed": False, + "muFwIntegrityIsFailed": False, + "suIntegrityIsFailed": False, + "logbookIntegrityIsFailed": False, + "logbookIsFull": False, + "memoryAccessIsFailed": False, + "muStateIsFailed": False, + } + }, + "publicKey": "string", + "publicKeyOcmf": "string", + "indexOfLastTransaction": 0, + "numberOfStoredTransactions": 99 + }) + +app.include_router(v1_api, prefix="/v1") + +def run_http_api(): + uvicorn.run("main:app", + host="0.0.0.0", + port=8000, reload=True) + +def run_https_api(): + uvicorn.run("main:app", + host="0.0.0.0", + port=8443, + reload=True, + ssl_keyfile=str(Path(__file__).parent / "./key.pem"), + ssl_certfile=str(Path(__file__).parent / "./certificate.pem")) + +if __name__ == "__main__": + p_http = Process(target=run_http_api) + p_https = Process(target=run_https_api) + p_http.start() + p_https.start() + p_http.join() + p_https.join() + diff --git a/modules/LemDCBM400600/utils/lem_dcbm_api_mock/poetry.lock b/modules/LemDCBM400600/utils/lem_dcbm_api_mock/poetry.lock new file mode 100644 index 000000000..ca86adb54 --- /dev/null +++ b/modules/LemDCBM400600/utils/lem_dcbm_api_mock/poetry.lock @@ -0,0 +1,298 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.5.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.7" +files = [ + {file = "annotated_types-0.5.0-py3-none-any.whl", hash = "sha256:58da39888f92c276ad970249761ebea80ba544b77acddaa1a4d6cf78287d45fd"}, + {file = "annotated_types-0.5.0.tar.gz", hash = "sha256:47cdc3490d9ac1506ce92c7aaa76c579dc3509ff11e098fc867e5130ab7be802"}, +] + +[[package]] +name = "anyio" +version = "3.7.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.7" +files = [ + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (<0.22)"] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "fastapi" +version = "0.103.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.7" +files = [ + {file = "fastapi-0.103.1-py3-none-any.whl", hash = "sha256:5e5f17e826dbd9e9b5a5145976c5cd90bcaa61f2bf9a69aca423f2bcebe44d83"}, + {file = "fastapi-0.103.1.tar.gz", hash = "sha256:345844e6a82062f06a096684196aaf96c1198b25c06b72c1311b882aa2d8a35d"}, +] + +[package.dependencies] +anyio = ">=3.7.1,<4.0.0" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.27.0,<0.28.0" +typing-extensions = ">=4.5.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "pydantic" +version = "2.3.0" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic-2.3.0-py3-none-any.whl", hash = "sha256:45b5e446c6dfaad9444819a293b921a40e1db1aa61ea08aede0522529ce90e81"}, + {file = "pydantic-2.3.0.tar.gz", hash = "sha256:1607cc106602284cd4a00882986570472f193fde9cb1259bceeaedb26aa79a6d"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.6.3" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.6.3" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic_core-2.6.3-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:1a0ddaa723c48af27d19f27f1c73bdc615c73686d763388c8683fe34ae777bad"}, + {file = "pydantic_core-2.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5cfde4fab34dd1e3a3f7f3db38182ab6c95e4ea91cf322242ee0be5c2f7e3d2f"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5493a7027bfc6b108e17c3383959485087d5942e87eb62bbac69829eae9bc1f7"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84e87c16f582f5c753b7f39a71bd6647255512191be2d2dbf49458c4ef024588"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:522a9c4a4d1924facce7270c84b5134c5cabcb01513213662a2e89cf28c1d309"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaafc776e5edc72b3cad1ccedb5fd869cc5c9a591f1213aa9eba31a781be9ac1"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a750a83b2728299ca12e003d73d1264ad0440f60f4fc9cee54acc489249b728"}, + {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e8b374ef41ad5c461efb7a140ce4730661aadf85958b5c6a3e9cf4e040ff4bb"}, + {file = "pydantic_core-2.6.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b594b64e8568cf09ee5c9501ede37066b9fc41d83d58f55b9952e32141256acd"}, + {file = "pydantic_core-2.6.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2a20c533cb80466c1d42a43a4521669ccad7cf2967830ac62c2c2f9cece63e7e"}, + {file = "pydantic_core-2.6.3-cp310-none-win32.whl", hash = "sha256:04fe5c0a43dec39aedba0ec9579001061d4653a9b53a1366b113aca4a3c05ca7"}, + {file = "pydantic_core-2.6.3-cp310-none-win_amd64.whl", hash = "sha256:6bf7d610ac8f0065a286002a23bcce241ea8248c71988bda538edcc90e0c39ad"}, + {file = "pydantic_core-2.6.3-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:6bcc1ad776fffe25ea5c187a028991c031a00ff92d012ca1cc4714087e575973"}, + {file = "pydantic_core-2.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:df14f6332834444b4a37685810216cc8fe1fe91f447332cd56294c984ecbff1c"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b7486d85293f7f0bbc39b34e1d8aa26210b450bbd3d245ec3d732864009819"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a892b5b1871b301ce20d40b037ffbe33d1407a39639c2b05356acfef5536d26a"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:883daa467865e5766931e07eb20f3e8152324f0adf52658f4d302242c12e2c32"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4eb77df2964b64ba190eee00b2312a1fd7a862af8918ec70fc2d6308f76ac64"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce8c84051fa292a5dc54018a40e2a1926fd17980a9422c973e3ebea017aa8da"}, + {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22134a4453bd59b7d1e895c455fe277af9d9d9fbbcb9dc3f4a97b8693e7e2c9b"}, + {file = "pydantic_core-2.6.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:02e1c385095efbd997311d85c6021d32369675c09bcbfff3b69d84e59dc103f6"}, + {file = "pydantic_core-2.6.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d79f1f2f7ebdb9b741296b69049ff44aedd95976bfee38eb4848820628a99b50"}, + {file = "pydantic_core-2.6.3-cp311-none-win32.whl", hash = "sha256:430ddd965ffd068dd70ef4e4d74f2c489c3a313adc28e829dd7262cc0d2dd1e8"}, + {file = "pydantic_core-2.6.3-cp311-none-win_amd64.whl", hash = "sha256:84f8bb34fe76c68c9d96b77c60cef093f5e660ef8e43a6cbfcd991017d375950"}, + {file = "pydantic_core-2.6.3-cp311-none-win_arm64.whl", hash = "sha256:5a2a3c9ef904dcdadb550eedf3291ec3f229431b0084666e2c2aa8ff99a103a2"}, + {file = "pydantic_core-2.6.3-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:8421cf496e746cf8d6b677502ed9a0d1e4e956586cd8b221e1312e0841c002d5"}, + {file = "pydantic_core-2.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bb128c30cf1df0ab78166ded1ecf876620fb9aac84d2413e8ea1594b588c735d"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37a822f630712817b6ecc09ccc378192ef5ff12e2c9bae97eb5968a6cdf3b862"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:240a015102a0c0cc8114f1cba6444499a8a4d0333e178bc504a5c2196defd456"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f90e5e3afb11268628c89f378f7a1ea3f2fe502a28af4192e30a6cdea1e7d5e"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:340e96c08de1069f3d022a85c2a8c63529fd88709468373b418f4cf2c949fb0e"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1480fa4682e8202b560dcdc9eeec1005f62a15742b813c88cdc01d44e85308e5"}, + {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f14546403c2a1d11a130b537dda28f07eb6c1805a43dae4617448074fd49c282"}, + {file = "pydantic_core-2.6.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a87c54e72aa2ef30189dc74427421e074ab4561cf2bf314589f6af5b37f45e6d"}, + {file = "pydantic_core-2.6.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f93255b3e4d64785554e544c1c76cd32f4a354fa79e2eeca5d16ac2e7fdd57aa"}, + {file = "pydantic_core-2.6.3-cp312-none-win32.whl", hash = "sha256:f70dc00a91311a1aea124e5f64569ea44c011b58433981313202c46bccbec0e1"}, + {file = "pydantic_core-2.6.3-cp312-none-win_amd64.whl", hash = "sha256:23470a23614c701b37252618e7851e595060a96a23016f9a084f3f92f5ed5881"}, + {file = "pydantic_core-2.6.3-cp312-none-win_arm64.whl", hash = "sha256:1ac1750df1b4339b543531ce793b8fd5c16660a95d13aecaab26b44ce11775e9"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:a53e3195f134bde03620d87a7e2b2f2046e0e5a8195e66d0f244d6d5b2f6d31b"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:f2969e8f72c6236c51f91fbb79c33821d12a811e2a94b7aa59c65f8dbdfad34a"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:672174480a85386dd2e681cadd7d951471ad0bb028ed744c895f11f9d51b9ebe"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:002d0ea50e17ed982c2d65b480bd975fc41086a5a2f9c924ef8fc54419d1dea3"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ccc13afee44b9006a73d2046068d4df96dc5b333bf3509d9a06d1b42db6d8bf"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:439a0de139556745ae53f9cc9668c6c2053444af940d3ef3ecad95b079bc9987"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d63b7545d489422d417a0cae6f9898618669608750fc5e62156957e609e728a5"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b44c42edc07a50a081672e25dfe6022554b47f91e793066a7b601ca290f71e42"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1c721bfc575d57305dd922e6a40a8fe3f762905851d694245807a351ad255c58"}, + {file = "pydantic_core-2.6.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5e4a2cf8c4543f37f5dc881de6c190de08096c53986381daebb56a355be5dfe6"}, + {file = "pydantic_core-2.6.3-cp37-none-win32.whl", hash = "sha256:d9b4916b21931b08096efed090327f8fe78e09ae8f5ad44e07f5c72a7eedb51b"}, + {file = "pydantic_core-2.6.3-cp37-none-win_amd64.whl", hash = "sha256:a8acc9dedd304da161eb071cc7ff1326aa5b66aadec9622b2574ad3ffe225525"}, + {file = "pydantic_core-2.6.3-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:5e9c068f36b9f396399d43bfb6defd4cc99c36215f6ff33ac8b9c14ba15bdf6b"}, + {file = "pydantic_core-2.6.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e61eae9b31799c32c5f9b7be906be3380e699e74b2db26c227c50a5fc7988698"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85463560c67fc65cd86153a4975d0b720b6d7725cf7ee0b2d291288433fc21b"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9616567800bdc83ce136e5847d41008a1d602213d024207b0ff6cab6753fe645"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e9b65a55bbabda7fccd3500192a79f6e474d8d36e78d1685496aad5f9dbd92c"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f468d520f47807d1eb5d27648393519655eadc578d5dd862d06873cce04c4d1b"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9680dd23055dd874173a3a63a44e7f5a13885a4cfd7e84814be71be24fba83db"}, + {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a718d56c4d55efcfc63f680f207c9f19c8376e5a8a67773535e6f7e80e93170"}, + {file = "pydantic_core-2.6.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8ecbac050856eb6c3046dea655b39216597e373aa8e50e134c0e202f9c47efec"}, + {file = "pydantic_core-2.6.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:788be9844a6e5c4612b74512a76b2153f1877cd845410d756841f6c3420230eb"}, + {file = "pydantic_core-2.6.3-cp38-none-win32.whl", hash = "sha256:07a1aec07333bf5adebd8264047d3dc518563d92aca6f2f5b36f505132399efc"}, + {file = "pydantic_core-2.6.3-cp38-none-win_amd64.whl", hash = "sha256:621afe25cc2b3c4ba05fff53525156d5100eb35c6e5a7cf31d66cc9e1963e378"}, + {file = "pydantic_core-2.6.3-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:813aab5bfb19c98ae370952b6f7190f1e28e565909bfc219a0909db168783465"}, + {file = "pydantic_core-2.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:50555ba3cb58f9861b7a48c493636b996a617db1a72c18da4d7f16d7b1b9952b"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19e20f8baedd7d987bd3f8005c146e6bcbda7cdeefc36fad50c66adb2dd2da48"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0a5d7edb76c1c57b95df719af703e796fc8e796447a1da939f97bfa8a918d60"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f06e21ad0b504658a3a9edd3d8530e8cea5723f6ea5d280e8db8efc625b47e49"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea053cefa008fda40f92aab937fb9f183cf8752e41dbc7bc68917884454c6362"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:171a4718860790f66d6c2eda1d95dd1edf64f864d2e9f9115840840cf5b5713f"}, + {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ed7ceca6aba5331ece96c0e328cd52f0dcf942b8895a1ed2642de50800b79d3"}, + {file = "pydantic_core-2.6.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:acafc4368b289a9f291e204d2c4c75908557d4f36bd3ae937914d4529bf62a76"}, + {file = "pydantic_core-2.6.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1aa712ba150d5105814e53cb141412217146fedc22621e9acff9236d77d2a5ef"}, + {file = "pydantic_core-2.6.3-cp39-none-win32.whl", hash = "sha256:44b4f937b992394a2e81a5c5ce716f3dcc1237281e81b80c748b2da6dd5cf29a"}, + {file = "pydantic_core-2.6.3-cp39-none-win_amd64.whl", hash = "sha256:9b33bf9658cb29ac1a517c11e865112316d09687d767d7a0e4a63d5c640d1b17"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d7050899026e708fb185e174c63ebc2c4ee7a0c17b0a96ebc50e1f76a231c057"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:99faba727727b2e59129c59542284efebbddade4f0ae6a29c8b8d3e1f437beb7"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fa159b902d22b283b680ef52b532b29554ea2a7fc39bf354064751369e9dbd7"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:046af9cfb5384f3684eeb3f58a48698ddab8dd870b4b3f67f825353a14441418"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:930bfe73e665ebce3f0da2c6d64455098aaa67e1a00323c74dc752627879fc67"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:85cc4d105747d2aa3c5cf3e37dac50141bff779545ba59a095f4a96b0a460e70"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b25afe9d5c4f60dcbbe2b277a79be114e2e65a16598db8abee2a2dcde24f162b"}, + {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e49ce7dc9f925e1fb010fc3d555250139df61fa6e5a0a95ce356329602c11ea9"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:2dd50d6a1aef0426a1d0199190c6c43ec89812b1f409e7fe44cb0fbf6dfa733c"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6595b0d8c8711e8e1dc389d52648b923b809f68ac1c6f0baa525c6440aa0daa"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ef724a059396751aef71e847178d66ad7fc3fc969a1a40c29f5aac1aa5f8784"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3c8945a105f1589ce8a693753b908815e0748f6279959a4530f6742e1994dcb6"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c8c6660089a25d45333cb9db56bb9e347241a6d7509838dbbd1931d0e19dbc7f"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:692b4ff5c4e828a38716cfa92667661a39886e71136c97b7dac26edef18767f7"}, + {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f1a5d8f18877474c80b7711d870db0eeef9442691fcdb00adabfc97e183ee0b0"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:3796a6152c545339d3b1652183e786df648ecdf7c4f9347e1d30e6750907f5bb"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b962700962f6e7a6bd77e5f37320cabac24b4c0f76afeac05e9f93cf0c620014"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56ea80269077003eaa59723bac1d8bacd2cd15ae30456f2890811efc1e3d4413"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75c0ebbebae71ed1e385f7dfd9b74c1cff09fed24a6df43d326dd7f12339ec34"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:252851b38bad3bfda47b104ffd077d4f9604a10cb06fe09d020016a25107bf98"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6656a0ae383d8cd7cc94e91de4e526407b3726049ce8d7939049cbfa426518c8"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d9140ded382a5b04a1c030b593ed9bf3088243a0a8b7fa9f071a5736498c5483"}, + {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d38bbcef58220f9c81e42c255ef0bf99735d8f11edef69ab0b499da77105158a"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:c9d469204abcca28926cbc28ce98f28e50e488767b084fb3fbdf21af11d3de26"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48c1ed8b02ffea4d5c9c220eda27af02b8149fe58526359b3c07eb391cb353a2"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b2b1bfed698fa410ab81982f681f5b1996d3d994ae8073286515ac4d165c2e7"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf9d42a71a4d7a7c1f14f629e5c30eac451a6fc81827d2beefd57d014c006c4a"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4292ca56751aebbe63a84bbfc3b5717abb09b14d4b4442cc43fd7c49a1529efd"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7dc2ce039c7290b4ef64334ec7e6ca6494de6eecc81e21cb4f73b9b39991408c"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:615a31b1629e12445c0e9fc8339b41aaa6cc60bd53bf802d5fe3d2c0cda2ae8d"}, + {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1fa1f6312fb84e8c281f32b39affe81984ccd484da6e9d65b3d18c202c666149"}, + {file = "pydantic_core-2.6.3.tar.gz", hash = "sha256:1508f37ba9e3ddc0189e6ff4e2228bd2d3c3a4641cbe8c07177162f76ed696c7"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + +[[package]] +name = "starlette" +version = "0.27.0" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.7" +files = [ + {file = "starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"}, + {file = "starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] + +[[package]] +name = "typing-extensions" +version = "4.7.1" +description = "Backported and Experimental Type Hints for Python 3.7+" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, +] + +[[package]] +name = "uvicorn" +version = "0.23.2" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.23.2-py3-none-any.whl", hash = "sha256:1f9be6558f01239d4fdf22ef8126c39cb1ad0addf76c40e760549d2c2f43ab53"}, + {file = "uvicorn-0.23.2.tar.gz", hash = "sha256:4d3cc12d7727ba72b64d12d3cc7743124074c0a69f7b201512fc50c3e3f1569a"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "46a2bfaab3735c239d54104de31ba29e2f4aca02d88e780577e07e57e3a07a79" diff --git a/modules/LemDCBM400600/utils/lem_dcbm_api_mock/pyproject.toml b/modules/LemDCBM400600/utils/lem_dcbm_api_mock/pyproject.toml new file mode 100644 index 000000000..102570fbe --- /dev/null +++ b/modules/LemDCBM400600/utils/lem_dcbm_api_mock/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "lem-dcbm-api-mock" +version = "0.1.0" +description = "" +authors = ["Fabian Klemm "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.103.1" +uvicorn = "^0.23.2" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api"