Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter
- CitrineOS core extracted (CSMS OCPP 2.0.1) - OpenOCPP extracted (firmware OCPP 1.6J/2.0.1) - ShapeShifter library installed (pip install -e) - ShapeShifter specification extracted - EVerest extracted TODO updated with progress
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
#
|
||||
# AUTO GENERATED - MARKED REGIONS WILL BE KEPT
|
||||
# template version 3
|
||||
#
|
||||
|
||||
# module setup:
|
||||
# - ${MODULE_NAME}: module name
|
||||
ev_setup_cpp_module()
|
||||
|
||||
# ev@bcc62523-e22b-41d7-ba2f-825b493a3c97:v1
|
||||
# insert your custom targets and additional config variables here
|
||||
target_link_libraries(${MODULE_NAME} PRIVATE CURL::libcurl)
|
||||
|
||||
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(EVEREST_CORE_BUILD_TESTING)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
# ev@c55432ab-152c-45a9-9d2e-7281d50c69c3:v1
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
#include "LemDCBM400600.hpp"
|
||||
|
||||
namespace module {
|
||||
|
||||
void LemDCBM400600::init() {
|
||||
invoke_init(*p_main);
|
||||
}
|
||||
|
||||
void LemDCBM400600::ready() {
|
||||
invoke_ready(*p_main);
|
||||
}
|
||||
|
||||
} // namespace module
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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 <generated/interfaces/powermeter/Implementation.hpp>
|
||||
|
||||
// ev@4bf81b14-a215-475c-a1d3-0a484ae48918:v1
|
||||
// insert your custom include headers here
|
||||
// ev@4bf81b14-a215-475c-a1d3-0a484ae48918:v1
|
||||
|
||||
namespace module {
|
||||
|
||||
struct Conf {
|
||||
std::string ip_address;
|
||||
int port;
|
||||
std::string interface;
|
||||
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;
|
||||
std::string meter_timezone;
|
||||
std::string meter_dst;
|
||||
int resilience_initial_connection_retries;
|
||||
int resilience_initial_connection_retry_delay;
|
||||
int resilience_transaction_request_retries;
|
||||
int resilience_transaction_request_retry_delay;
|
||||
int cable_id;
|
||||
int tariff_id;
|
||||
int IT;
|
||||
int SC;
|
||||
std::string UV;
|
||||
std::string UD;
|
||||
double temperature_warning_level_C;
|
||||
double temperature_error_level_C;
|
||||
double temperature_hysteresis_K;
|
||||
int temperature_min_time_as_valid_ms;
|
||||
int command_timeout_ms;
|
||||
};
|
||||
|
||||
class LemDCBM400600 : public Everest::ModuleBase {
|
||||
public:
|
||||
LemDCBM400600() = delete;
|
||||
LemDCBM400600(const ModuleInfo& info, std::unique_ptr<powermeterImplBase> p_main, Conf& config) :
|
||||
ModuleBase(info), p_main(std::move(p_main)), config(config){};
|
||||
|
||||
const std::unique_ptr<powermeterImplBase> p_main;
|
||||
const Conf& config;
|
||||
|
||||
// ev@1fce4c5e-0ab8-41bb-90f7-14277703d2ac:v1
|
||||
// insert your public definitions here
|
||||
// ev@1fce4c5e-0ab8-41bb-90f7-14277703d2ac:v1
|
||||
|
||||
protected:
|
||||
// ev@4714b2ab-a24f-4b95-ab81-36439e1478de:v1
|
||||
// insert your protected definitions here
|
||||
// ev@4714b2ab-a24f-4b95-ab81-36439e1478de:v1
|
||||
|
||||
private:
|
||||
friend class LdEverest;
|
||||
void init();
|
||||
void ready();
|
||||
|
||||
// ev@211cfdbe-f69a-4cd6-a4ec-f8aaa3d1b6c8:v1
|
||||
// insert your private definitions here
|
||||
// ev@211cfdbe-f69a-4cd6-a4ec-f8aaa3d1b6c8:v1
|
||||
};
|
||||
|
||||
// ev@087e516b-124c-48df-94fb-109508c7cda9:v1
|
||||
// insert other definitions here
|
||||
// ev@087e516b-124c-48df-94fb-109508c7cda9:v1
|
||||
|
||||
} // namespace module
|
||||
|
||||
#endif // LEM_DCBM400600_HPP
|
||||
@@ -0,0 +1,166 @@
|
||||
.. _everest_modules_handwritten_LemDCBM400600:
|
||||
|
||||
.. ****************
|
||||
.. LEM DCBM 400/600
|
||||
.. ****************
|
||||
|
||||
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 <TLS Notes>` 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
|
||||
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://<DEVICE ADDRESS>:<DEVICE PORT>/v1/certificate'
|
||||
|
||||
TLS can be enabled via:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
curl --location --request PUT 'https://<DEVICE ADDRESS>:<DEVICE PORT>/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 <https://www.lem.com/en/dcbm-400-600>`_
|
||||
- `Operation Manual <https://www.lem.com/en/file/10314/download>`_
|
||||
- `Communication protocols manual <https://www.lem.com/en/file/11215/download>`_
|
||||
@@ -0,0 +1,214 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#include "http_client.hpp"
|
||||
#include <fmt/core.h>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace module::main {
|
||||
const char* CONTENT_TYPE_HEADER = "Content-Type: application/json";
|
||||
|
||||
struct payloadInTransit {
|
||||
const std::string& data;
|
||||
size_t position;
|
||||
};
|
||||
|
||||
// Callback for receiving data, saves it into a string
|
||||
static size_t receive_data(char* ptr, size_t size, size_t nmemb, std::string* received_data) {
|
||||
received_data->append(ptr, size * nmemb);
|
||||
return size * nmemb;
|
||||
}
|
||||
|
||||
// Callback for sending data, fetches it from a string
|
||||
static size_t send_data(char* buffer, size_t size, size_t nitems, struct payloadInTransit* payload) {
|
||||
if (payload->position >= payload->data.length()) {
|
||||
// Returning 0 signals to libcurl that we have no more data to send
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Send up to size*nitems bytes of data
|
||||
size_t payload_remaining_bytes = payload->data.length() - payload->position;
|
||||
size_t num_bytes_to_send = std::min(size * nitems, payload_remaining_bytes);
|
||||
std::memcpy(buffer, payload->data.c_str() + payload->position, num_bytes_to_send);
|
||||
payload->position += num_bytes_to_send;
|
||||
return num_bytes_to_send;
|
||||
}
|
||||
|
||||
static HttpClientError client_error(const std::string& host, unsigned int port, const char* method,
|
||||
const std::string& path, const std::string& message) {
|
||||
return HttpClientError(fmt::format("HTTP client error on {} {}:{}{} : {} ", method, host, port, path, message));
|
||||
}
|
||||
|
||||
static void setup_connection(CURL* connection, struct payloadInTransit& request_payload, std::string& response_body,
|
||||
curl_slist*& headers, const int command_timeout_ms) {
|
||||
// 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);
|
||||
curl_easy_setopt(connection, CURLOPT_TIMEOUT_MS, command_timeout_ms);
|
||||
|
||||
// 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, command_timeout_ms);
|
||||
|
||||
// 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?");
|
||||
}
|
||||
if (!this->network_interface.empty()) {
|
||||
if (curl_easy_setopt(connection, CURLOPT_INTERFACE, this->network_interface.c_str()) != CURLE_OK) {
|
||||
throw std::runtime_error("Could not bind to the specified network interface: " + this->network_interface);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#ifndef EVEREST_CORE_MODULE_HTTPCLIENT_H
|
||||
#define EVEREST_CORE_MODULE_HTTPCLIENT_H
|
||||
|
||||
#include "fmt/format.h"
|
||||
#include "http_client_interface.hpp"
|
||||
#include <curl/curl.h>
|
||||
#include <everest/logging.hpp>
|
||||
#include <regex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
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,
|
||||
const std::string& network_interface = "") {
|
||||
// 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);
|
||||
this->network_interface = network_interface;
|
||||
}
|
||||
|
||||
~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();
|
||||
}
|
||||
|
||||
void set_command_timeout(const int command_timeout_ms) override {
|
||||
this->command_timeout_ms = command_timeout_ms;
|
||||
}
|
||||
[[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;
|
||||
int command_timeout_ms = 5000; // default timeout in milliseconds
|
||||
std::string network_interface; // network interface
|
||||
|
||||
[[nodiscard]] CURL* create_curl_handle_and_setup_url(const std::string& path) const;
|
||||
HttpResponse perform_request(CURL* connection, const std::string& request_body, const char* method_name,
|
||||
const std::string& path) const;
|
||||
};
|
||||
|
||||
} // namespace module::main
|
||||
|
||||
#endif // EVEREST_CORE_MODULE_HTTPCLIENT_H
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#ifndef EVEREST_CORE_MODULE_HTTP_CLIENT_INTERFACE_H
|
||||
#define EVEREST_CORE_MODULE_HTTP_CLIENT_INTERFACE_H
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace module::main {
|
||||
|
||||
class HttpClientError : public std::exception {
|
||||
public:
|
||||
[[nodiscard]] const char* what() const noexcept override {
|
||||
return this->reason.c_str();
|
||||
}
|
||||
explicit HttpClientError(std::string msg) {
|
||||
this->reason = std::move(msg);
|
||||
}
|
||||
explicit HttpClientError(const char* msg) {
|
||||
this->reason = std::string(msg);
|
||||
}
|
||||
|
||||
private:
|
||||
std::string reason;
|
||||
};
|
||||
|
||||
struct HttpResponse {
|
||||
unsigned int status_code;
|
||||
std::string body;
|
||||
};
|
||||
|
||||
struct HttpClientInterface {
|
||||
|
||||
virtual ~HttpClientInterface() = default;
|
||||
|
||||
virtual void set_command_timeout(const int command_timeout_ms){};
|
||||
[[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_CORE_MODULE_HTTP_CLIENT_INTERFACE_H
|
||||
@@ -0,0 +1,409 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#include "lem_dcbm_400600_controller.hpp"
|
||||
#include <stdexcept>
|
||||
namespace module::main {
|
||||
|
||||
void LemDCBM400600Controller::init() {
|
||||
EVLOG_info << "LEM DCBM 400/600: Try to communicate with the device to initialize it.";
|
||||
this->time_sync_helper->set_time_config_params(config.meter_timezone, config.meter_dst);
|
||||
this->http_client->set_command_timeout(this->config.command_timeout_ms);
|
||||
|
||||
if (this->config.IT >= 0) {
|
||||
call_with_retry(
|
||||
[this]() {
|
||||
const int current_it = this->get_identification_type();
|
||||
if (current_it != this->config.IT) {
|
||||
EVLOG_info << "LEM DCBM 400/600: Setting OCMF Identification Type (IT) to: " << this->config.IT;
|
||||
this->set_identification_type(this->config.IT);
|
||||
} else {
|
||||
EVLOG_info << "LEM DCBM 400/600: OCMF Identification Type (IT) already set to " << current_it
|
||||
<< ", skipping write";
|
||||
}
|
||||
},
|
||||
this->config.init_number_of_http_retries, this->config.init_retry_wait_in_milliseconds);
|
||||
}
|
||||
|
||||
call_with_retry([this]() { this->fetch_meter_id_from_device(); }, this->config.init_number_of_http_retries,
|
||||
this->config.init_retry_wait_in_milliseconds);
|
||||
|
||||
this->time_sync_helper->restart_unsafe_period();
|
||||
EVLOG_info << "LEM DCBM 400/600: Device initialized successfully.";
|
||||
}
|
||||
|
||||
std::vector<std::string> split(const std::string& str, char delimiter) {
|
||||
std::vector<std::string> tokens;
|
||||
std::string token;
|
||||
std::stringstream ss(str);
|
||||
|
||||
while (std::getline(ss, token, delimiter)) {
|
||||
tokens.push_back(token);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
std::string LemDCBM400600Controller::get_current_transaction() {
|
||||
const std::string endpoint = v2_capable ? "/v2/legal" : "/v1/legal";
|
||||
auto response = this->http_client->get(endpoint);
|
||||
if (response.status_code != 200) {
|
||||
throw UnexpectedDCBMResponseCode(endpoint, 200, response);
|
||||
}
|
||||
try {
|
||||
json data = json::parse(response.body);
|
||||
return data.at("transactionId");
|
||||
} catch (json::exception& json_error) {
|
||||
throw UnexpectedDCBMResponseBody(endpoint, fmt::format("Json error '{}'", json_error.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void LemDCBM400600Controller::update_lem_status() {
|
||||
// should call this after a communication error to figure out what has been happening
|
||||
auto status_response = this->http_client->get("/v1/status");
|
||||
|
||||
if (status_response.status_code != 200) {
|
||||
throw UnexpectedDCBMResponseCode("/v1/status", 200, status_response);
|
||||
}
|
||||
try {
|
||||
json data = json::parse(status_response.body);
|
||||
this->need_to_stop_transaction = data.at("status").at("bits").at("transactionIsOnGoing");
|
||||
this->current_transaction_id = get_current_transaction();
|
||||
if (this->need_to_stop_transaction) {
|
||||
// we need to get the current transaction id or the last known transaction id since we might
|
||||
// receive a stop transaction with id 0 or with the last known transaction if
|
||||
// meaning that the system had a failure and the transaction was started but id was not saved
|
||||
// and we try to recover from the error, thus we need to cancel the transaction
|
||||
EVLOG_warning << "LEM DCBM 400/600: A transaction is already ongoing and it has the id:"
|
||||
<< this->current_transaction_id;
|
||||
} else {
|
||||
EVLOG_info << "LEM DCBM 400/600: The last known transaction has the id:" << this->current_transaction_id;
|
||||
}
|
||||
} catch (json::exception& json_error) {
|
||||
throw UnexpectedDCBMResponseBody(
|
||||
"/v1/status", fmt::format("Json error {} for body {}", json_error.what(), status_response.body));
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
json data = json::parse(status_response.body);
|
||||
this->meter_id = data.at("meterId");
|
||||
this->public_key_ocmf = data.at("publicKeyOcmf");
|
||||
this->need_to_stop_transaction = data.at("status").at("bits").at("transactionIsOnGoing");
|
||||
std::string version = data.at("version").at("applicationFirmwareVersion");
|
||||
auto components = split(version, '.');
|
||||
this->v2_capable =
|
||||
((components.size() == 4) && (components[1] > "1")); // the major version must be newer than 1
|
||||
this->current_transaction_id = get_current_transaction();
|
||||
if (this->need_to_stop_transaction) {
|
||||
// we need to get the current transaction id or the last known transaction id since we might
|
||||
// receive a stop transaction with id 0 or with the last known transaction if
|
||||
// meaning that the system had a failure and the transaction was started but id was not saved
|
||||
// and we try to recover from the error, thus we need to cancel the transaction
|
||||
EVLOG_warning << "LEM DCBM 400/600: A transaction is already ongoing and it has the id:"
|
||||
<< this->current_transaction_id;
|
||||
} else {
|
||||
EVLOG_info << "LEM DCBM 400/600: The last known transaction has the id:" << this->current_transaction_id;
|
||||
}
|
||||
} 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 {
|
||||
if (this->need_to_stop_transaction) {
|
||||
// there is already an ongoing transaction, something went wrong, we will clean
|
||||
// the current transaction
|
||||
EVLOG_error << "LEM DCBM 400/600: A transaction with the id " << this->current_transaction_id
|
||||
<< "already exists but the system is trying to start a new transaction with the id:"
|
||||
<< value.transaction_id << ", try to recover by closing the current transaction";
|
||||
try {
|
||||
// we will not return any response to stop transaction since this is a self triggered command
|
||||
this->request_device_to_stop_transaction(this->current_transaction_id);
|
||||
this->need_to_stop_transaction = false;
|
||||
} catch (UnexpectedDCBMResponseCode& error) {
|
||||
EVLOG_error << "LEM DCBM 400/600: Could not close the current transaction, got error:" << error.what();
|
||||
}
|
||||
}
|
||||
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);
|
||||
this->current_transaction_id = value.transaction_id;
|
||||
this->need_to_stop_transaction = true;
|
||||
} 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);
|
||||
|
||||
const std::string endpoint = v2_capable ? "/v2/legal" : "/v1/legal";
|
||||
const std::string payload = this->transaction_start_request_to_dcbm_payload(value);
|
||||
auto response = this->http_client->post(endpoint, payload);
|
||||
if (response.status_code != 201) {
|
||||
throw UnexpectedDCBMResponseCode(endpoint, 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(endpoint,
|
||||
fmt::format("Json error {} for body '{}'", json_error.what(), response.body));
|
||||
}
|
||||
}
|
||||
|
||||
types::powermeter::TransactionStopResponse
|
||||
LemDCBM400600Controller::stop_transaction(const std::string& transaction_id) {
|
||||
std::string tid = transaction_id;
|
||||
bool need_to_execute_device_stop_transaction = true;
|
||||
if (!(this->need_to_stop_transaction) && transaction_id == this->current_transaction_id) {
|
||||
// transaction is not open but we need to provide OCMF information about it
|
||||
need_to_execute_device_stop_transaction = false;
|
||||
this->current_transaction_id = "";
|
||||
}
|
||||
if (!(this->need_to_stop_transaction) && transaction_id.empty()) {
|
||||
// return an error because there is no transaction initially ongoing (at start up time)
|
||||
return types::powermeter::TransactionStopResponse{
|
||||
types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, {}, {}, "No dangling transaction open"};
|
||||
}
|
||||
if (this->need_to_stop_transaction && transaction_id == this->current_transaction_id) {
|
||||
// transaction has been found and it is now going to close
|
||||
this->need_to_stop_transaction = false;
|
||||
this->current_transaction_id = "";
|
||||
}
|
||||
if (this->need_to_stop_transaction && transaction_id.empty()) {
|
||||
// transaction has NOT been found by the system, however the system is requesting a cleanup
|
||||
// thus we use the last known value of the transaction id to close the transaction
|
||||
tid = this->current_transaction_id;
|
||||
this->current_transaction_id = "";
|
||||
this->need_to_stop_transaction = false;
|
||||
}
|
||||
try {
|
||||
return call_with_retry(
|
||||
[this, need_to_execute_device_stop_transaction, tid]() {
|
||||
// special case if we started and a transaction is ongoing - the upper layers might not know the
|
||||
// transaction id
|
||||
if (need_to_execute_device_stop_transaction) {
|
||||
this->request_device_to_stop_transaction(tid);
|
||||
}
|
||||
auto signed_meter_value = types::units_signed::SignedMeterValue{fetch_ocmf_result(tid), "", "OCMF"};
|
||||
signed_meter_value.public_key.emplace(public_key_ocmf);
|
||||
return types::powermeter::TransactionStopResponse{types::powermeter::TransactionRequestStatus::OK,
|
||||
{}, // Empty start_signed_meter_value
|
||||
signed_meter_value};
|
||||
},
|
||||
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 {}: {}", tid, 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: {}", tid, error.what());
|
||||
EVLOG_error << error_message;
|
||||
// if we have the last known OCMF value, we can return it
|
||||
if (current_signed_meter_value.public_key.has_value()) {
|
||||
EVLOG_warning << "LEM DCBM 400/600: Returning the last known OCMF value for transaction " << tid
|
||||
<< " with value: " << current_signed_meter_value;
|
||||
return types::powermeter::TransactionStopResponse{
|
||||
types::powermeter::TransactionRequestStatus::OK, {}, current_signed_meter_value};
|
||||
}
|
||||
current_signed_meter_value = types::units_signed::SignedMeterValue{};
|
||||
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 = v2_capable ? fmt::format("/v2/legal?transactionId={}", transaction_id)
|
||||
: 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 = v2_capable ? fmt::format("/v2/ocmf?transactionId={}", transaction_id)
|
||||
: 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);
|
||||
|
||||
const std::string endpoint = v2_capable ? "/v2/livemeasure" : "/v1/livemeasure";
|
||||
auto response = this->http_client->get(endpoint);
|
||||
types::powermeter::Powermeter powermeter_result;
|
||||
if (response.status_code != 200) {
|
||||
throw UnexpectedDCBMResponseCode(endpoint, 200, response);
|
||||
}
|
||||
try {
|
||||
powermeter_result = this->convert_livemeasure_to_powermeter(response.body);
|
||||
} catch (json::exception& json_error) {
|
||||
throw UnexpectedDCBMResponseBody(endpoint, fmt::format("Json error '{}'", json_error.what()));
|
||||
}
|
||||
if (this->need_to_stop_transaction) {
|
||||
// if there is no ongoing transaction, we do need to fetch the signed meter value to have it available
|
||||
// for the upper layers, otherwise we will not have the OCMF value if we lose connection to the device
|
||||
try {
|
||||
current_signed_meter_value =
|
||||
types::units_signed::SignedMeterValue{fetch_ocmf_result(current_transaction_id), "", "OCMF"};
|
||||
current_signed_meter_value.public_key.emplace(public_key_ocmf);
|
||||
current_signed_meter_value.timestamp.emplace(powermeter_result.timestamp);
|
||||
} catch (UnexpectedDCBMResponseCode& error) {
|
||||
EVLOG_error << "LEM DCBM 400/600: Could not get the OCMF value: " << error.what();
|
||||
} catch (UnexpectedDCBMResponseBody& error) {
|
||||
EVLOG_error << "LEM DCBM 400/600: Invalid OCMF value: " << error.what();
|
||||
} catch (HttpClientError& error) {
|
||||
std::string error_message = fmt::format("Failed get the OCMF field {} - connection to device failed: {}",
|
||||
current_transaction_id, error.what());
|
||||
EVLOG_error << error_message;
|
||||
}
|
||||
}
|
||||
return powermeter_result;
|
||||
}
|
||||
|
||||
types::powermeter::Powermeter
|
||||
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").get<float>() * 1000.0f};
|
||||
powermeter.energy_Wh_export.emplace(types::units::Energy{data.at("energyExportTotal").get<float>() * 1000.0f});
|
||||
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").get<float>() * 1000.0f});
|
||||
powermeter.temperatures.emplace({types::temperature::Temperature{data.at("temperatureH"), "temperatureH"},
|
||||
types::temperature::Temperature{data.at("temperatureL"), "temperatureL"}});
|
||||
return powermeter;
|
||||
}
|
||||
|
||||
std::string
|
||||
LemDCBM400600Controller::transaction_start_request_to_dcbm_payload(const types::powermeter::TransactionReq& request) {
|
||||
std::string client_id = request.identification_data.value_or("") + ',' + request.transaction_id;
|
||||
const int max_length_client_id = 37; // as defined by LEM documentation
|
||||
client_id = (client_id.length() > max_length_client_id) ? client_id.substr(0, max_length_client_id) : client_id;
|
||||
if (this->v2_capable) {
|
||||
return nlohmann::ordered_json{{"evseId", request.evse_id},
|
||||
{"transactionId", request.transaction_id},
|
||||
{"clientId", client_id},
|
||||
{"tariffId", this->config.tariff_id},
|
||||
{"TT", request.tariff_text.value_or("")},
|
||||
{"UV", this->config.UV},
|
||||
{"UD", this->config.UD},
|
||||
{"cableId", this->config.cable_id},
|
||||
{"userData", ""},
|
||||
{"SC", this->config.SC}}
|
||||
.dump();
|
||||
} else {
|
||||
return nlohmann::ordered_json{
|
||||
{"evseId", request.evse_id}, {"transactionId", request.transaction_id}, {"clientId", client_id},
|
||||
{"tariffId", this->config.tariff_id}, {"cableId", this->config.cable_id}, {"userData", ""}}
|
||||
.dump();
|
||||
}
|
||||
}
|
||||
|
||||
void LemDCBM400600Controller::set_identification_type(int identification_type) {
|
||||
const std::string payload = nlohmann::ordered_json{{"ocmfId", {{"IT", identification_type}}}}.dump();
|
||||
auto response = this->http_client->put("/v1/settings", payload);
|
||||
if (response.status_code != 200) {
|
||||
throw UnexpectedDCBMResponseCode("/v1/settings", 200, response);
|
||||
}
|
||||
try {
|
||||
bool success = nlohmann::json::parse(response.body).at("result") == 1;
|
||||
if (!success) {
|
||||
throw UnexpectedDCBMResponseBody("/v1/settings",
|
||||
"OCMF Identification Type setting was rejected by the device.");
|
||||
}
|
||||
} catch (nlohmann::json::exception& json_error) {
|
||||
throw UnexpectedDCBMResponseBody(
|
||||
"/v1/settings", fmt::format("Json error '{}' for body '{}'", json_error.what(), response.body));
|
||||
}
|
||||
EVLOG_info << "LEM DCBM 400/600: OCMF Identification Type (IT) set to: " << identification_type;
|
||||
}
|
||||
|
||||
int LemDCBM400600Controller::get_identification_type() {
|
||||
auto response = this->http_client->get("/v1/settings");
|
||||
if (response.status_code != 200) {
|
||||
throw UnexpectedDCBMResponseCode("/v1/settings", 200, response);
|
||||
}
|
||||
try {
|
||||
return nlohmann::json::parse(response.body).at("ocmfId").at("IT").get<int>();
|
||||
} catch (nlohmann::json::exception& json_error) {
|
||||
throw UnexpectedDCBMResponseBody(
|
||||
"/v1/settings", fmt::format("Json error '{}' for body '{}'", json_error.what(), response.body));
|
||||
}
|
||||
}
|
||||
|
||||
std::pair<std::string, std::string> 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<date::utc_clock>::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
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#ifndef EVEREST_CORE_MODULE_LEMDCBM400600_H
|
||||
#define EVEREST_CORE_MODULE_LEMDCBM400600_H
|
||||
|
||||
#include "http_client_interface.hpp"
|
||||
#include "lem_dcbm_time_sync_helper.hpp"
|
||||
#include <functional>
|
||||
#include <generated/interfaces/powermeter/Implementation.hpp>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
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;
|
||||
// The cable loss compensation level to use. This allows compensating the measurements of the DCBM with a
|
||||
// resistance.
|
||||
const int cable_id;
|
||||
// Used for a unique transaction tariff designation
|
||||
const int tariff_id;
|
||||
// meter time zone
|
||||
const std::string meter_timezone;
|
||||
// the meter Daylight Saving Time (DST) settings
|
||||
const std::string meter_dst;
|
||||
// SC
|
||||
const int SC;
|
||||
// UV
|
||||
const std::string UV;
|
||||
// UD
|
||||
const std::string UD;
|
||||
// OCMF Identification Type (set on device at startup via /settings/ocmfId/IT, -1 = not set)
|
||||
const int IT;
|
||||
// command timeout in milliseconds
|
||||
const int command_timeout_ms;
|
||||
};
|
||||
|
||||
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) {
|
||||
}
|
||||
};
|
||||
|
||||
void update_lem_status();
|
||||
|
||||
private:
|
||||
const std::unique_ptr<HttpClientInterface> http_client;
|
||||
std::string meter_id;
|
||||
std::string public_key;
|
||||
std::string public_key_ocmf;
|
||||
std::string version;
|
||||
bool v2_capable = false;
|
||||
bool need_to_stop_transaction = false;
|
||||
std::string current_transaction_id;
|
||||
types::units_signed::SignedMeterValue current_signed_meter_value;
|
||||
std::unique_ptr<LemDCBMTimeSyncHelper> time_sync_helper;
|
||||
Conf config;
|
||||
|
||||
void fetch_meter_id_from_device();
|
||||
int get_identification_type();
|
||||
void set_identification_type(int identification_type);
|
||||
std::string get_current_transaction();
|
||||
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);
|
||||
types::powermeter::Powermeter convert_livemeasure_to_powermeter(const std::string& livemeasure);
|
||||
std::string transaction_start_request_to_dcbm_payload(const types::powermeter::TransactionReq& request);
|
||||
static std::pair<std::string, std::string> get_transaction_stop_time_bounds();
|
||||
|
||||
template <typename Callable>
|
||||
static auto call_with_retry(Callable func, int number_of_retries, int retry_wait_in_milliseconds,
|
||||
bool retry_on_http_client_error = true, bool retry_on_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<HttpClientInterface> http_client,
|
||||
std::unique_ptr<LemDCBMTimeSyncHelper> 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();
|
||||
inline bool is_initialized() {
|
||||
return ("" != meter_id);
|
||||
}
|
||||
inline std::string get_public_key_ocmf() {
|
||||
return public_key_ocmf;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace module::main
|
||||
|
||||
#endif // EVEREST_CORE_MODULE_LEMDCBM400600_H
|
||||
@@ -0,0 +1,174 @@
|
||||
// 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 <boost/algorithm/string.hpp>
|
||||
#include <date/tz.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <utils/date.hpp>
|
||||
|
||||
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::set_time_config_params(const std::string& meter_timezone, const std::string& meter_dst) {
|
||||
this->meter_timezone = meter_timezone;
|
||||
this->meter_dst = meter_dst;
|
||||
}
|
||||
|
||||
void LemDCBMTimeSyncHelper::sync_if_deadline_expired(const HttpClientInterface& httpClient) {
|
||||
const std::lock_guard<std::recursive_mutex> 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<std::recursive_mutex> 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);
|
||||
this->sync_timezone(httpClient);
|
||||
this->sync_dst(httpClient);
|
||||
} else {
|
||||
this->sync_system_time(httpClient);
|
||||
this->sync_timezone(httpClient);
|
||||
this->sync_dst(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());
|
||||
std::string payload = R"({"time":{"utc":")" + time_update + R"("}})";
|
||||
HttpResponse response = httpClient.put("/v1/settings", payload);
|
||||
|
||||
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::sync_timezone(const HttpClientInterface& httpClient) {
|
||||
std::string payload = std::string(R"({"time": {"tz":")") + meter_timezone + R"("}})";
|
||||
HttpResponse response = httpClient.put("/v1/settings", payload);
|
||||
|
||||
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", "Timezone 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::sync_dst(const HttpClientInterface& httpClient) {
|
||||
std::string payload = std::string(R"({"time": {"dst":)") + meter_dst + R"(}})";
|
||||
HttpResponse response = httpClient.put("/v1/settings", payload);
|
||||
|
||||
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",
|
||||
"Daylight saving 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
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#ifndef EVEREST_CORE_MODULE_LEM_DCBM_TIME_SYNC_HELPER_H
|
||||
#define EVEREST_CORE_MODULE_LEM_DCBM_TIME_SYNC_HELPER_H
|
||||
|
||||
#include "http_client_interface.hpp"
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
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) :
|
||||
ntp_spec(std::move(ntp_spec)),
|
||||
timing_constants(tc),
|
||||
meter_timezone(""),
|
||||
meter_dst(""),
|
||||
unsafe_period_start_time({}) {
|
||||
}
|
||||
|
||||
virtual ~LemDCBMTimeSyncHelper() = default;
|
||||
|
||||
void set_time_config_params(const std::string& meter_timezone, const std::string& meter_dst);
|
||||
|
||||
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;
|
||||
// the meter timezone
|
||||
std::string meter_timezone;
|
||||
// the meter daylight saving time definition
|
||||
std::string meter_dst;
|
||||
|
||||
// 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<std::chrono::steady_clock> deadline_for_next_sync;
|
||||
std::optional<std::chrono::time_point<std::chrono::steady_clock>> 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);
|
||||
void sync_timezone(const HttpClientInterface& httpClient);
|
||||
void sync_dst(const HttpClientInterface& httpClient);
|
||||
|
||||
std::string generate_dcbm_ntp_config();
|
||||
[[nodiscard]] bool is_setting_write_safe() const;
|
||||
};
|
||||
|
||||
} // namespace module::main
|
||||
|
||||
#endif // EVEREST_CORE_MODULE_LEM_DCBM_TIME_SYNC_HELPER_H
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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 <chrono>
|
||||
#include <everest/logging.hpp>
|
||||
#include <fmt/core.h>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
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<HttpClient>(mod->config.ip_address, mod->config.port,
|
||||
mod->config.meter_tls_certificate, mod->config.interface);
|
||||
|
||||
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<LemDCBM400600Controller>(
|
||||
std::move(http_client), std::make_unique<LemDCBMTimeSyncHelper>(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,
|
||||
mod->config.cable_id, mod->config.tariff_id, mod->config.meter_timezone, mod->config.meter_dst,
|
||||
mod->config.SC, mod->config.UV, mod->config.UD, mod->config.IT, mod->config.command_timeout_ms});
|
||||
|
||||
// Validate and normalize temperature thresholds for the monitor.
|
||||
// If the error level is configured below the warning level, clamp it and log a warning.
|
||||
double warning_level_C = mod->config.temperature_warning_level_C;
|
||||
double error_level_C = mod->config.temperature_error_level_C;
|
||||
if (error_level_C < warning_level_C) {
|
||||
EVLOG_warning << "LEM DCBM 400/600: temperature_error_level_C (" << error_level_C
|
||||
<< " °C) is below temperature_warning_level_C (" << warning_level_C
|
||||
<< " °C). Clamping error level to the warning level.";
|
||||
error_level_C = warning_level_C;
|
||||
}
|
||||
|
||||
this->temperature_monitor = std::make_unique<TemperatureMonitor>(
|
||||
TemperatureMonitor::Config{warning_level_C, error_level_C, mod->config.temperature_hysteresis_K,
|
||||
std::chrono::milliseconds(mod->config.temperature_min_time_as_valid_ms)});
|
||||
}
|
||||
|
||||
void powermeterImpl::ready() {
|
||||
// Start the live_measure_publisher thread, which periodically publishes the live measurements of the device
|
||||
this->live_measure_publisher_thread = std::thread([this] {
|
||||
while (true) {
|
||||
try {
|
||||
if (!this->controller->is_initialized()) {
|
||||
this->controller->init();
|
||||
this->publish_public_key_ocmf(this->controller->get_public_key_ocmf());
|
||||
std::this_thread::sleep_for(
|
||||
std::chrono::milliseconds(mod->config.resilience_initial_connection_retry_delay));
|
||||
} else {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
|
||||
auto powermeter_data = this->controller->get_powermeter();
|
||||
this->publish_powermeter(powermeter_data);
|
||||
// if the communication error is set, clear the error
|
||||
if (this->error_state_monitor->is_error_active("powermeter/CommunicationFault",
|
||||
"Communication timed out")) {
|
||||
// need to update LEM status since we have recovered from a communication loss
|
||||
this->controller->update_lem_status();
|
||||
clear_error("powermeter/CommunicationFault", "Communication timed out");
|
||||
}
|
||||
|
||||
// Evaluate temperature thresholds
|
||||
if (powermeter_data.temperatures.has_value() && powermeter_data.temperatures->size() >= 2) {
|
||||
const double temp_H = powermeter_data.temperatures->at(0).temperature;
|
||||
const double temp_L = powermeter_data.temperatures->at(1).temperature;
|
||||
auto events = this->temperature_monitor->update(temp_H, temp_L);
|
||||
handle_temperature_events(events, this->temperature_monitor->last_max_temperature());
|
||||
}
|
||||
}
|
||||
} catch (LemDCBM400600Controller::DCBMUnexpectedResponseException& error) {
|
||||
EVLOG_error << "LEM DCBM 400/600: Failed to execute the powermeter ready loop due to an invalid device "
|
||||
"response: "
|
||||
<< error.what();
|
||||
} catch (HttpClientError& client_error) {
|
||||
if (!this->error_state_monitor->is_error_active("powermeter/CommunicationFault",
|
||||
"Communication timed out")) {
|
||||
EVLOG_error << "Failed to communicate with the powermeter due to http error: "
|
||||
<< client_error.what();
|
||||
auto error =
|
||||
this->error_factory->create_error("powermeter/CommunicationFault", "Communication timed out",
|
||||
"This error is raised due to communication timeout");
|
||||
raise_error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
types::powermeter::TransactionStartResponse
|
||||
powermeterImpl::handle_start_transaction(types::powermeter::TransactionReq& value) {
|
||||
if (!this->controller->is_initialized()) {
|
||||
return types::powermeter::TransactionStartResponse{
|
||||
types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, {}, {}, "Powermeter is not initialized"};
|
||||
}
|
||||
return this->controller->start_transaction(value);
|
||||
}
|
||||
|
||||
types::powermeter::TransactionStopResponse powermeterImpl::handle_stop_transaction(std::string& transaction_id) {
|
||||
if (!this->controller->is_initialized()) {
|
||||
return types::powermeter::TransactionStopResponse{
|
||||
types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR, {}, {}, "Powermeter is not initialized"};
|
||||
}
|
||||
return this->controller->stop_transaction(transaction_id);
|
||||
}
|
||||
|
||||
void powermeterImpl::handle_temperature_events(const TemperatureMonitor::Events& events, double max_temperature) {
|
||||
if (events.warning_raised) {
|
||||
EVLOG_warning << fmt::format(
|
||||
"LEM DCBM 400/600: Temperature warning raised — max temperature {:.1f} °C exceeds warning level {:.1f} °C",
|
||||
max_temperature, mod->config.temperature_warning_level_C);
|
||||
auto error =
|
||||
this->error_factory->create_error("powermeter/VendorWarning", "TemperatureWarning",
|
||||
fmt::format("Max temperature {:.1f} °C exceeds warning level {:.1f} °C",
|
||||
max_temperature, mod->config.temperature_warning_level_C));
|
||||
raise_error(error);
|
||||
}
|
||||
if (events.warning_cleared) {
|
||||
EVLOG_info << fmt::format(
|
||||
"LEM DCBM 400/600: Temperature warning cleared — max temperature {:.1f} °C dropped below {:.1f} °C",
|
||||
max_temperature, mod->config.temperature_warning_level_C - mod->config.temperature_hysteresis_K);
|
||||
clear_error("powermeter/VendorWarning", "TemperatureWarning");
|
||||
}
|
||||
if (events.error_raised) {
|
||||
EVLOG_error << fmt::format(
|
||||
"LEM DCBM 400/600: Temperature error raised — max temperature {:.1f} °C exceeds error level {:.1f} °C",
|
||||
max_temperature, mod->config.temperature_error_level_C);
|
||||
auto error =
|
||||
this->error_factory->create_error("powermeter/VendorError", "TemperatureError",
|
||||
fmt::format("Max temperature {:.1f} °C exceeds error level {:.1f} °C",
|
||||
max_temperature, mod->config.temperature_error_level_C));
|
||||
raise_error(error);
|
||||
}
|
||||
if (events.error_cleared) {
|
||||
EVLOG_info << fmt::format(
|
||||
"LEM DCBM 400/600: Temperature error cleared — max temperature {:.1f} °C dropped below {:.1f} °C",
|
||||
max_temperature, mod->config.temperature_error_level_C - mod->config.temperature_hysteresis_K);
|
||||
clear_error("powermeter/VendorError", "TemperatureError");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace module::main
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
#ifndef MAIN_POWERMETER_IMPL_HPP
|
||||
#define MAIN_POWERMETER_IMPL_HPP
|
||||
|
||||
//
|
||||
// AUTO GENERATED - MARKED REGIONS WILL BE KEPT
|
||||
// template version 3
|
||||
//
|
||||
|
||||
#include <generated/interfaces/powermeter/Implementation.hpp>
|
||||
|
||||
#include "../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"
|
||||
#include "temperature_monitor.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<LemDCBM400600>& 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<LemDCBM400600>& 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<LemDCBM400600Controller> 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;
|
||||
|
||||
// Temperature monitoring with warning/error thresholds and hysteresis
|
||||
std::unique_ptr<TemperatureMonitor> temperature_monitor;
|
||||
void handle_temperature_events(const TemperatureMonitor::Events& events, double max_temperature);
|
||||
// ev@3370e4dd-95f4-47a9-aaec-ea76f34a66c9:v1
|
||||
};
|
||||
|
||||
// ev@3d7da0ad-02c2-493d-9920-0bbbd56b9876:v1
|
||||
// insert other definitions here
|
||||
// ev@3d7da0ad-02c2-493d-9920-0bbbd56b9876:v1
|
||||
|
||||
} // namespace main
|
||||
} // namespace module
|
||||
|
||||
#endif // MAIN_POWERMETER_IMPL_HPP
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#ifndef LEM_DCBM_TEMPERATURE_MONITOR_HPP
|
||||
#define LEM_DCBM_TEMPERATURE_MONITOR_HPP
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
|
||||
namespace module::main {
|
||||
|
||||
/// Monitors temperature readings against warning and error thresholds
|
||||
/// with hysteresis and a minimum exceedance duration before raising events.
|
||||
class TemperatureMonitor {
|
||||
public:
|
||||
struct Config {
|
||||
double warning_level_C;
|
||||
double error_level_C;
|
||||
double hysteresis_K;
|
||||
std::chrono::milliseconds min_time_as_valid;
|
||||
};
|
||||
|
||||
struct Events {
|
||||
bool warning_raised{false};
|
||||
bool warning_cleared{false};
|
||||
bool error_raised{false};
|
||||
bool error_cleared{false};
|
||||
};
|
||||
|
||||
explicit TemperatureMonitor(const Config& config) : config_(config) {
|
||||
}
|
||||
|
||||
/// Feed new temperature readings and get back any state-change events.
|
||||
/// The evaluation uses max(temperature_H, temperature_L).
|
||||
Events update(double temperature_H_C, double temperature_L_C) {
|
||||
last_max_temperature_ = std::max(temperature_H_C, temperature_L_C);
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
|
||||
Events events;
|
||||
evaluate_level(warning_active_, warning_exceeded_since_, config_.warning_level_C, now, events.warning_raised,
|
||||
events.warning_cleared);
|
||||
evaluate_level(error_active_, error_exceeded_since_, config_.error_level_C, now, events.error_raised,
|
||||
events.error_cleared);
|
||||
return events;
|
||||
}
|
||||
|
||||
/// Returns the current max temperature from the last update (for logging).
|
||||
[[nodiscard]] double last_max_temperature() const {
|
||||
return last_max_temperature_;
|
||||
}
|
||||
|
||||
private:
|
||||
Config config_;
|
||||
|
||||
bool warning_active_{false};
|
||||
std::optional<std::chrono::steady_clock::time_point> warning_exceeded_since_;
|
||||
|
||||
bool error_active_{false};
|
||||
std::optional<std::chrono::steady_clock::time_point> error_exceeded_since_;
|
||||
|
||||
double last_max_temperature_{0.0};
|
||||
|
||||
void evaluate_level(bool& active, std::optional<std::chrono::steady_clock::time_point>& exceeded_since,
|
||||
double threshold, std::chrono::steady_clock::time_point now, bool& raised_event,
|
||||
bool& cleared_event) {
|
||||
|
||||
if (!active) {
|
||||
// Not yet active — check if we should start or continue timing
|
||||
if (last_max_temperature_ >= threshold) {
|
||||
if (!exceeded_since.has_value()) {
|
||||
// First time exceeding: start the timer
|
||||
exceeded_since = now;
|
||||
}
|
||||
// Check if minimum exceedance duration has elapsed
|
||||
if ((now - exceeded_since.value()) >= config_.min_time_as_valid) {
|
||||
active = true;
|
||||
exceeded_since.reset();
|
||||
raised_event = true;
|
||||
}
|
||||
} else {
|
||||
// Temperature dropped below threshold before timer expired — reset
|
||||
exceeded_since.reset();
|
||||
}
|
||||
} else {
|
||||
// Active — check if we should clear (with hysteresis)
|
||||
if (last_max_temperature_ < (threshold - config_.hysteresis_K)) {
|
||||
active = false;
|
||||
exceeded_since.reset();
|
||||
cleared_event = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace module::main
|
||||
|
||||
#endif // LEM_DCBM_TEMPERATURE_MONITOR_HPP
|
||||
@@ -0,0 +1,142 @@
|
||||
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
|
||||
interface:
|
||||
description: Bind to a specific network interface. If left empty, the module will bind to all interfaces.
|
||||
type: string
|
||||
default: ""
|
||||
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
|
||||
meter_timezone:
|
||||
description: The timezone offset (ignored if NTP servers are set) - it can go from -11 to +14 for hours and 00, 15, 30, 45 for minutes
|
||||
type: string
|
||||
default: "+00:00"
|
||||
meter_dst:
|
||||
description: The Daylight Saving Time (DST) settings (ignored if NTP is set)
|
||||
type: string
|
||||
default: '{"activated": false, "offset": 60, "start": {"order": "last", "day": "sunday", "month": "march", "hour": "T01:00Z"}, "end": {"order": "last", "day": "sunday", "month": "october", "hour": "T01:00Z" }}'
|
||||
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
|
||||
cable_id:
|
||||
description: The cable loss compensation level to use. This allows compensating the measurements of the DCBM with a resistance.
|
||||
type: integer
|
||||
default: 0
|
||||
tariff_id:
|
||||
description: Used for a unique transaction tariff designation
|
||||
type: integer
|
||||
default: 0
|
||||
IT:
|
||||
description: >-
|
||||
OCMF Identification Type (set on device at startup via /settings/ocmfId/IT).
|
||||
Values per LEM DCBM Communication Protocol 4.2.10.3:
|
||||
0=NONE, 1=DENIED, 2=UNDEFINED, 3=ISO14443, 4=ISO15693,
|
||||
5=EMAID, 6=EVCCID, 7=EVCOID, 8=ISO7812, 9=CARD_TXN_NR,
|
||||
10=CENTRAL, 11=CENTRAL_1, 12=CENTRAL_2, 13=LOCAL, 14=LOCAL_1,
|
||||
15=LOCAL_2, 16=PHONE_NUMBER, 17=KEY_CODE.
|
||||
A value of -1 means the field is not set on the device.
|
||||
By setting this value you can control the content of the ID field of OCMF record.
|
||||
If this value is not set, ID is set to the transaction id.
|
||||
Please check the device documentation for more details.
|
||||
|
||||
WARNING: The device documentation states: "For correct usage the IT fields shall
|
||||
be set once to the corresponding protocol used (or kept to NONE as per default value),
|
||||
and not be changed during the lifetime of the DCBM due to current software limitation."
|
||||
type: integer
|
||||
minimum: -1
|
||||
maximum: 17
|
||||
default: -1
|
||||
SC:
|
||||
description: SC (OCMF/transaction fields)
|
||||
type: integer
|
||||
default: 0
|
||||
UV:
|
||||
description: User SW Version (OCMF/transaction fields)
|
||||
type: string
|
||||
default: ""
|
||||
UD:
|
||||
description: UD (OCMF/transaction fields)
|
||||
type: string
|
||||
default: ""
|
||||
temperature_warning_level_C:
|
||||
description: >-
|
||||
Temperature warning threshold in degrees Celsius. If the maximum of temperatureH
|
||||
and temperatureL exceeds this value for at least temperature_min_time_as_valid_ms,
|
||||
a VendorWarning is raised. Charging continues. Default of 5000 effectively disables this.
|
||||
type: number
|
||||
default: 5000
|
||||
temperature_error_level_C:
|
||||
description: >-
|
||||
Temperature error threshold in degrees Celsius. If the maximum of temperatureH
|
||||
and temperatureL exceeds this value for at least temperature_min_time_as_valid_ms,
|
||||
a VendorError is raised. Charging is stopped. Default of 5000 effectively disables this.
|
||||
type: number
|
||||
default: 5000
|
||||
temperature_hysteresis_K:
|
||||
description: >-
|
||||
Hysteresis in Kelvin for clearing temperature warnings and errors.
|
||||
A warning is cleared when max temperature drops below warning_level - hysteresis.
|
||||
An error is cleared when max temperature drops below error_level - hysteresis.
|
||||
type: number
|
||||
default: 3
|
||||
minimum: 0
|
||||
temperature_min_time_as_valid_ms:
|
||||
description: >-
|
||||
Minimum time in milliseconds that the temperature must continuously exceed
|
||||
the warning or error level before the corresponding event is raised.
|
||||
type: integer
|
||||
default: 5000
|
||||
minimum: 0
|
||||
command_timeout_ms:
|
||||
description: The timeout in milliseconds for a HTTP command to the LEM power meter.
|
||||
type: integer
|
||||
default: 5000
|
||||
maximum: 20000
|
||||
minimum: 1000
|
||||
|
||||
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
|
||||
@@ -0,0 +1,127 @@
|
||||
set(TEST_TARGET_NAME ${PROJECT_NAME}_lem_dcbm_400600_controller_tests)
|
||||
|
||||
set(MODULE_DIR "${PROJECT_SOURCE_DIR}/modules/HardwareDrivers/PowerMeters/LemDCBM400600")
|
||||
|
||||
add_executable(${TEST_TARGET_NAME}
|
||||
test_lem_dcbm_400600_controller.cpp
|
||||
"${MODULE_DIR}/main/lem_dcbm_400600_controller.cpp"
|
||||
"${MODULE_DIR}/main/lem_dcbm_time_sync_helper.cpp"
|
||||
)
|
||||
add_dependencies(${TEST_TARGET_NAME} ${MODULE_NAME})
|
||||
|
||||
set(INCLUDE_DIR
|
||||
"main"
|
||||
"tests"
|
||||
"${MODULE_DIR}/main"
|
||||
"${MODULE_DIR}/tests")
|
||||
|
||||
get_target_property(GENERATED_INCLUDE_DIR generate_cpp_files EVEREST_GENERATED_INCLUDE_DIR)
|
||||
|
||||
target_include_directories(${TEST_TARGET_NAME} PRIVATE
|
||||
tests
|
||||
${INCLUDE_DIR}
|
||||
${GENERATED_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
#
|
||||
target_link_libraries(${TEST_TARGET_NAME} PRIVATE
|
||||
GTest::gmock_main
|
||||
everest::timer
|
||||
everest::framework
|
||||
nlohmann_json::nlohmann_json
|
||||
CURL::libcurl
|
||||
)
|
||||
|
||||
|
||||
add_test(${TEST_TARGET_NAME} ${TEST_TARGET_NAME})
|
||||
ev_register_test_target(${TEST_TARGET_NAME})
|
||||
|
||||
## Time sync helper test
|
||||
set(TEST_TARGET_NAME ${PROJECT_NAME}_lem_time_sync_helper_tests)
|
||||
add_executable(${TEST_TARGET_NAME}
|
||||
test_lem_dcbm_time_sync_helper.cpp
|
||||
"${MODULE_DIR}/main/lem_dcbm_time_sync_helper.cpp"
|
||||
)
|
||||
|
||||
add_dependencies(${TEST_TARGET_NAME} ${MODULE_NAME})
|
||||
|
||||
set(INCLUDE_DIR
|
||||
"main"
|
||||
"tests"
|
||||
"${MODULE_DIR}/main"
|
||||
"${MODULE_DIR}/tests")
|
||||
|
||||
get_target_property(GENERATED_INCLUDE_DIR generate_cpp_files EVEREST_GENERATED_INCLUDE_DIR)
|
||||
|
||||
target_include_directories(${TEST_TARGET_NAME} PRIVATE
|
||||
tests
|
||||
${INCLUDE_DIR}
|
||||
${GENERATED_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
#
|
||||
target_link_libraries(${TEST_TARGET_NAME} PRIVATE
|
||||
GTest::gmock_main
|
||||
everest::timer
|
||||
everest::framework
|
||||
nlohmann_json::nlohmann_json
|
||||
CURL::libcurl
|
||||
)
|
||||
|
||||
add_test(${TEST_TARGET_NAME} ${TEST_TARGET_NAME})
|
||||
ev_register_test_target(${TEST_TARGET_NAME})
|
||||
|
||||
## Temperature monitor test
|
||||
set(TEST_TARGET_NAME ${PROJECT_NAME}_lem_temperature_monitor_tests)
|
||||
add_executable(${TEST_TARGET_NAME}
|
||||
test_temperature_monitor.cpp
|
||||
)
|
||||
|
||||
add_dependencies(${TEST_TARGET_NAME} ${MODULE_NAME})
|
||||
|
||||
set(INCLUDE_DIR
|
||||
"main"
|
||||
"tests"
|
||||
"${MODULE_DIR}/main"
|
||||
"${MODULE_DIR}/tests")
|
||||
|
||||
get_target_property(GENERATED_INCLUDE_DIR generate_cpp_files EVEREST_GENERATED_INCLUDE_DIR)
|
||||
|
||||
target_include_directories(${TEST_TARGET_NAME} PRIVATE
|
||||
tests
|
||||
${INCLUDE_DIR}
|
||||
${GENERATED_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(${TEST_TARGET_NAME} PRIVATE
|
||||
GTest::gmock_main
|
||||
everest::timer
|
||||
everest::framework
|
||||
nlohmann_json::nlohmann_json
|
||||
CURL::libcurl
|
||||
)
|
||||
|
||||
add_test(${TEST_TARGET_NAME} ${TEST_TARGET_NAME})
|
||||
ev_register_test_target(${TEST_TARGET_NAME})
|
||||
|
||||
## http client integration client
|
||||
|
||||
add_executable(integration_test_http_client
|
||||
integration_test_http_client.cpp
|
||||
"${MODULE_DIR}/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
|
||||
)
|
||||
@@ -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=<Path to workspace / required Everest repositories>
|
||||
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 <Projekt root directory>/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/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 <Build dir>/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=<Build dir>/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.
|
||||
@@ -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()
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#include "http_client.hpp"
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
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<bool>(), 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<bool>(), true);
|
||||
}
|
||||
|
||||
class HttpClientIntegrationTestWithCert : public ::testing::TestWithParam<std::string> {
|
||||
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
|
||||
@@ -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"])
|
||||
@@ -0,0 +1,93 @@
|
||||
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.allow):
|
||||
status: str = Field("OK", const=True, 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
log_cli=true
|
||||
log_level=debug
|
||||
asyncio_mode=strict
|
||||
@@ -0,0 +1,8 @@
|
||||
settings:
|
||||
telemetry_enabled: true
|
||||
active_modules:
|
||||
lem_dcbm_controller:
|
||||
config_module:
|
||||
ip_address: "localhost"
|
||||
port: 8000
|
||||
module: LemDCBM400600
|
||||
@@ -0,0 +1,651 @@
|
||||
// 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 <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
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<HTTPClientMock> http_client;
|
||||
std::unique_ptr<LemDCBMTimeSyncHelperMock> 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",
|
||||
types::powermeter::OCMFUserIdentificationStatus::ASSIGNED,
|
||||
{},
|
||||
types::powermeter::OCMFIdentificationType::ISO14443,
|
||||
std::nullopt,
|
||||
std::nullopt,
|
||||
std::nullopt};
|
||||
|
||||
const std::string expected_start_transaction_request_body{
|
||||
R"({"evseId":"mock_evse_id","transactionId":"mock_transaction_id","clientId":",mock_transaction_id","tariffId":0,"cableId":0,"userData":""})"};
|
||||
|
||||
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"
|
||||
})";
|
||||
|
||||
// IT = -1 so that init() does not call set_identification_type()
|
||||
const LemDCBM400600Controller::Conf controller_config{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, -1};
|
||||
|
||||
void SetUp() override {
|
||||
this->http_client = std::make_unique<HTTPClientMock>();
|
||||
this->time_sync_helper = std::make_unique<LemDCBMTimeSyncHelperMock>();
|
||||
}
|
||||
};
|
||||
|
||||
// Extended fixture for parametrizing tests for invalid response checks
|
||||
class LemDCBM400600ControllerTestInvalidResponses
|
||||
: public LemDCBM400600ControllerTest,
|
||||
public ::testing::WithParamInterface<testing::internal::ReturnAction<HttpResponse>> {};
|
||||
|
||||
//****************************************************************
|
||||
// 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(1000.0));
|
||||
EXPECT_THAT(powermeter.energy_Wh_export->total, testing::FloatEq(2000.0));
|
||||
EXPECT_THAT(powermeter.power_W->total, testing::FloatEq(3000.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_FALSE(res.error.has_value());
|
||||
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_TRUE(res.error.has_value());
|
||||
EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to start transaction mock_transaction_id.*"));
|
||||
EXPECT_FALSE(res.transaction_min_stop_time.has_value());
|
||||
EXPECT_FALSE(res.transaction_max_stop_time.has_value());
|
||||
}
|
||||
|
||||
// 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<LemDCBM400600ControllerTestInvalidResponses::ParamType>& 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_TRUE(res.error.has_value());
|
||||
EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to start transaction mock_transaction_id.*"));
|
||||
EXPECT_FALSE(res.transaction_min_stop_time.has_value());
|
||||
EXPECT_FALSE(res.transaction_max_stop_time.has_value());
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// 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
|
||||
ASSERT_EQ(transaction_request_status_to_string(res.status), "OK");
|
||||
ASSERT_TRUE(res.signed_meter_value.has_value());
|
||||
ASSERT_EQ(res.signed_meter_value.value().signed_meter_data, "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_TRUE(res.error.has_value());
|
||||
EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to stop transaction mock_transaction_id:.*"));
|
||||
EXPECT_FALSE(res.signed_meter_value.has_value());
|
||||
}
|
||||
|
||||
// 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<LemDCBM400600ControllerTestInvalidResponses::ParamType>& 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_TRUE(res.error.has_value());
|
||||
EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to stop transaction mock_transaction_id.*"));
|
||||
EXPECT_FALSE(res.signed_meter_value.has_value());
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// 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)
|
||||
.WillOnce(testing::Return(HttpResponse{
|
||||
200,
|
||||
this->livemeasure_response,
|
||||
}));
|
||||
EXPECT_CALL(*this->http_client, get("/v1/status"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{
|
||||
200,
|
||||
R"({ "meterId": "mock_meter_id", "publicKeyOcmf": "KEY", "status": {"bits": {"transactionIsOnGoing": false}}, "version":{"applicationFirmwareVersion":"0.1.2.3"}, "some_other_field": "other_value" })",
|
||||
}));
|
||||
EXPECT_CALL(*this->http_client, get("/v1/legal"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"transactionId": "thetransactionid"})"}));
|
||||
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)
|
||||
.WillOnce(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", "publicKeyOcmf": "KEY", "status": {"bits": {"transactionIsOnGoing": false}}, "version":{"applicationFirmwareVersion":"0.1.2.3"}, "some_other_field": "other_value" })",
|
||||
}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/legal"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"transactionId": "thetransactionid"})"}));
|
||||
|
||||
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, 0, 0, {}, {}, 0, {}, {}, -1};
|
||||
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, 0, 0, {}, {}, 0, {}, {}, -1};
|
||||
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);
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// Test set_identification_type behavior (IT field)
|
||||
|
||||
// Helper to set up a mock that successfully completes init() (fetch_meter_id + restart + set_command_timeout)
|
||||
#define SETUP_SUCCESSFUL_INIT(seq) \
|
||||
EXPECT_CALL(*this->http_client, get("/v1/status")) \
|
||||
.Times(1) \
|
||||
.InSequence(seq) \
|
||||
.WillOnce(testing::Return(HttpResponse{ \
|
||||
200, \
|
||||
R"({ "meterId": "mock_meter_id", "publicKeyOcmf": "KEY", "status": {"bits": {"transactionIsOnGoing": false}}, "version":{"applicationFirmwareVersion":"0.1.2.3"} })", \
|
||||
})); \
|
||||
EXPECT_CALL(*this->http_client, get("/v1/legal")) \
|
||||
.Times(1) \
|
||||
.InSequence(seq) \
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"transactionId": "thetransactionid"})"})); \
|
||||
EXPECT_CALL(*this->time_sync_helper, restart_unsafe_period()).Times(1).InSequence(seq)
|
||||
|
||||
/// \brief Test init() with IT = -1 does not call set_identification_type
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_skips_set_it_when_minus_one) {
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
SETUP_SUCCESSFUL_INIT(seq);
|
||||
|
||||
// No PUT /v1/settings expected
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::_)).Times(0);
|
||||
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config); // IT = -1
|
||||
|
||||
// Act
|
||||
controller.init();
|
||||
}
|
||||
|
||||
/// \brief Test init() with IT >= 0 calls set_identification_type successfully
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_sets_it_when_valid) {
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":3}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", R"({"ocmfId":{"IT":5}})"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"result": 1})"}));
|
||||
|
||||
SETUP_SUCCESSFUL_INIT(seq);
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 5};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act - should not throw
|
||||
controller.init();
|
||||
}
|
||||
|
||||
/// \brief Test set_identification_type throws on non-200 response code
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_set_it_fails_on_bad_status_code) {
|
||||
// Setup - IT is attempted first; fetch_meter_id is never reached when IT fails
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":0}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", R"({"ocmfId":{"IT":3}})"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{500, "Internal Server Error"}));
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 3};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.init(), LemDCBM400600Controller::DCBMUnexpectedResponseException);
|
||||
}
|
||||
|
||||
/// \brief Test set_identification_type throws when device rejects the setting (result != 1)
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_set_it_fails_on_rejected) {
|
||||
// Setup - IT is attempted first; fetch_meter_id is never reached when IT fails
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":0}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", R"({"ocmfId":{"IT":3}})"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"result": 0})"}));
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 3};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.init(), LemDCBM400600Controller::DCBMUnexpectedResponseException);
|
||||
}
|
||||
|
||||
/// \brief Test set_identification_type throws on malformed JSON response body
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_set_it_fails_on_malformed_json) {
|
||||
// Setup - IT is attempted first; fetch_meter_id is never reached when IT fails
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":0}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", R"({"ocmfId":{"IT":3}})"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, "not json"}));
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 3};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.init(), LemDCBM400600Controller::DCBMUnexpectedResponseException);
|
||||
}
|
||||
|
||||
/// \brief Test set_identification_type throws when JSON body is missing "result" key
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_set_it_fails_on_missing_result_key) {
|
||||
// Setup - IT is attempted first; fetch_meter_id is never reached when IT fails
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":0}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", R"({"ocmfId":{"IT":3}})"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"status": "ok"})"}));
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 3};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.init(), LemDCBM400600Controller::DCBMUnexpectedResponseException);
|
||||
}
|
||||
|
||||
/// \brief Test init() with IT >= 0 skips set_identification_type when device already has the configured value
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_skips_set_it_when_already_configured) {
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":5}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::_)).Times(0);
|
||||
|
||||
SETUP_SUCCESSFUL_INIT(seq);
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 5};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act - should not throw
|
||||
controller.init();
|
||||
}
|
||||
|
||||
} // namespace module::main
|
||||
@@ -0,0 +1,194 @@
|
||||
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"""
|
||||
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"""
|
||||
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"""
|
||||
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",
|
||||
"identification_status": "ASSIGNED",
|
||||
"identification_flags": []
|
||||
}})
|
||||
|
||||
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",
|
||||
"identification_status": "ASSIGNED",
|
||||
"identification_flags": [],
|
||||
"identification_type": "ISO14443"
|
||||
}})["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",
|
||||
"identification_status": "ASSIGNED",
|
||||
"identification_flags": [],
|
||||
"identification_type": "ISO14443"
|
||||
}})["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}"
|
||||
@@ -0,0 +1,46 @@
|
||||
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"""
|
||||
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": "e2e_test_transaction",
|
||||
"identification_status": "ASSIGNED",
|
||||
"identification_flags": [],
|
||||
"identification_type": "ISO14443"
|
||||
}})
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,292 @@
|
||||
// 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 <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
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<HTTPClientMock> 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_tz_sync_request_regex{R"(\{"time": \{"tz":""\}\})"};
|
||||
|
||||
const std::string expected_dst_sync_request_regex{R"(\{"time": \{"dst":\}\})"};
|
||||
|
||||
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<HTTPClientMock>();
|
||||
}
|
||||
};
|
||||
|
||||
// Extended fixture for parametrizing tests for invalid response checks
|
||||
class LemDCBMTimeSyncHelperTestInvalidResponses
|
||||
: public LemDCBMTimeSyncHelperTest,
|
||||
public ::testing::WithParamInterface<testing::internal::ReturnAction<HttpResponse>> {};
|
||||
|
||||
//****************************************************************
|
||||
|
||||
/// \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_dst_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
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", testing::ContainsRegex(this->expected_dst_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
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_dst_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
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", testing::ContainsRegex(this->expected_dst_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
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", testing::ContainsRegex(this->expected_dst_sync_request_regex)))
|
||||
.Times(2)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}))
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(2)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}))
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
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", testing::ContainsRegex(this->expected_dst_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
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
|
||||
@@ -0,0 +1,213 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <thread>
|
||||
|
||||
#include "temperature_monitor.hpp"
|
||||
|
||||
using module::main::TemperatureMonitor;
|
||||
|
||||
namespace {
|
||||
|
||||
TemperatureMonitor make_monitor(double warning_level_C, double error_level_C, double hysteresis_K,
|
||||
int min_time_as_valid_ms) {
|
||||
return TemperatureMonitor(TemperatureMonitor::Config{warning_level_C, error_level_C, hysteresis_K,
|
||||
std::chrono::milliseconds(min_time_as_valid_ms)});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(TemperatureMonitorTest, NoEventsBelowThresholds) {
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, /*min_time_ms*/ 0);
|
||||
|
||||
auto events = monitor.update(40.0, 41.0);
|
||||
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, WarningRaisedImmediatelyWhenAboveWarningAndZeroMinTime) {
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, /*min_time_ms*/ 0);
|
||||
|
||||
auto events = monitor.update(51.0, 49.0); // max = 51 > 50
|
||||
|
||||
EXPECT_TRUE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, ErrorRaisedImmediatelyWhenAboveErrorAndZeroMinTime) {
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, /*min_time_ms*/ 0);
|
||||
|
||||
auto events = monitor.update(61.0, 59.0); // max = 61 > 60 (also > 50 warning threshold)
|
||||
|
||||
// When temperature exceeds error threshold, it also exceeds warning threshold,
|
||||
// so both warning and error should be raised
|
||||
EXPECT_TRUE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
EXPECT_TRUE(events.error_raised);
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, WarningClearsWithHysteresis) {
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, /*min_time_ms*/ 0);
|
||||
|
||||
// Raise warning
|
||||
auto events = monitor.update(52.0, 51.0);
|
||||
EXPECT_TRUE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
|
||||
// Still above warning - hysteresis threshold, no clear
|
||||
events = monitor.update(49.0, 48.5); // max = 49, threshold - hyst = 47
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
|
||||
// Drop below warning - hysteresis threshold, should clear
|
||||
events = monitor.update(46.0, 45.0); // max = 46 < 47
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_TRUE(events.warning_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, ErrorClearsWithHysteresis) {
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 5.0, /*min_time_ms*/ 0);
|
||||
|
||||
// Raise error
|
||||
auto events = monitor.update(61.0, 62.0);
|
||||
EXPECT_TRUE(events.error_raised);
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
|
||||
// Still above error - hysteresis threshold, no clear
|
||||
events = monitor.update(56.0, 55.0); // error threshold - hyst = 55
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
|
||||
// Drop below error - hysteresis threshold, should clear
|
||||
events = monitor.update(54.0, 53.0); // max = 54 < 55
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_TRUE(events.error_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, WarningNotRaisedBeforeMinTimeElapses) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// First update: temperature exceeds threshold, but min_time hasn't elapsed
|
||||
auto events = monitor.update(51.0, 50.5); // max = 51 > 50
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
|
||||
// Wait less than min_time - still no warning
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 2));
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, WarningRaisedAfterMinTimeElapses) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// First update: temperature exceeds threshold, starts timer
|
||||
auto events = monitor.update(51.0, 50.5); // max = 51 > 50
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
// Wait for min_time to elapse
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms + 10)); // Add small buffer for test timing
|
||||
|
||||
// Next update after min_time elapsed should raise warning
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_TRUE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, TimerResetsWhenTemperatureDropsBelowThreshold) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// Start timing: temperature exceeds threshold
|
||||
auto events = monitor.update(51.0, 50.5); // max = 51 > 50
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
// Wait part of the min_time
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 2));
|
||||
|
||||
// Temperature drops below threshold - timer should reset
|
||||
events = monitor.update(49.0, 48.0); // max = 49 < 50
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
|
||||
// Wait again for min_time - but timer was reset, so we need to start over
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 2));
|
||||
events = monitor.update(49.0, 48.0);
|
||||
EXPECT_FALSE(events.warning_raised); // Still below threshold
|
||||
|
||||
// Now exceed threshold again and wait full min_time
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms + 10));
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_TRUE(events.warning_raised); // Now should be raised
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, ErrorNotRaisedBeforeMinTimeElapses) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// Temperature exceeds error threshold
|
||||
auto events = monitor.update(61.0, 60.5); // max = 61 > 60
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.warning_raised); // Also above warning, but min_time applies
|
||||
|
||||
// Wait less than min_time
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 2));
|
||||
events = monitor.update(61.0, 60.5);
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, ErrorRaisedAfterMinTimeElapses) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// Start timing: temperature exceeds error threshold
|
||||
auto events = monitor.update(61.0, 60.5); // max = 61 > 60
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
// Wait for min_time to elapse
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms + 10));
|
||||
|
||||
// Next update after min_time elapsed should raise both warning and error
|
||||
events = monitor.update(61.0, 60.5);
|
||||
EXPECT_TRUE(events.error_raised);
|
||||
EXPECT_TRUE(events.warning_raised); // Also above warning threshold
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, MultipleUpdatesAccumulateTimeCorrectly) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// First update starts timer
|
||||
auto events = monitor.update(51.0, 50.5);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
// Multiple updates while above threshold should accumulate time
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 3));
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 3));
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
// Final update after total time >= min_time should raise warning
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 3 + 10));
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_TRUE(events.warning_raised);
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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()
|
||||
|
||||
325
tools/EVerest-main/modules/HardwareDrivers/PowerMeters/LemDCBM400600/utils/lem_dcbm_api_mock/poetry.lock
generated
Normal file
325
tools/EVerest-main/modules/HardwareDrivers/PowerMeters/LemDCBM400600/utils/lem_dcbm_api_mock/poetry.lock
generated
Normal file
@@ -0,0 +1,325 @@
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
description = "Reusable constraint types to use with typing.Annotated"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
|
||||
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "3.7.1"
|
||||
description = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
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) ; python_version < \"3.8\"", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17) ; python_version < \"3.12\" and platform_python_implementation == \"CPython\" and platform_system != \"Windows\""]
|
||||
trio = ["trio (<0.22)"]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.1"
|
||||
description = "Composable command line interface toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
|
||||
{file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
|
||||
]
|
||||
|
||||
[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"
|
||||
groups = ["main"]
|
||||
markers = "platform_system == \"Windows\""
|
||||
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.2"
|
||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "fastapi-0.103.2-py3-none-any.whl", hash = "sha256:3270de872f0fe9ec809d4bd3d4d890c6d5cc7b9611d721d6438f9dacc8c4ef2e"},
|
||||
{file = "fastapi-0.103.2.tar.gz", hash = "sha256:75a11f6bfb8fc4d2bec0bd710c2d5f2829659c0e8c0afd5560fdda6ce25ec653"},
|
||||
]
|
||||
|
||||
[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.16.0"
|
||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
||||
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
|
||||
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.7"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"},
|
||||
{file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-types = ">=0.6.0"
|
||||
pydantic-core = "2.33.2"
|
||||
typing-extensions = ">=4.12.2"
|
||||
typing-inspection = ">=0.4.0"
|
||||
|
||||
[package.extras]
|
||||
email = ["email-validator (>=2.0.0)"]
|
||||
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
description = "Core functionality for Pydantic validation and serialization"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"},
|
||||
{file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
description = "Sniff out which async library your code is running under"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
|
||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.27.0"
|
||||
description = "The little ASGI library that shines."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
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.14.1"
|
||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"},
|
||||
{file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.1"
|
||||
description = "Runtime typing introspection tools"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
|
||||
{file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.23.2"
|
||||
description = "The lightning-fast ASGI server."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
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) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.11"
|
||||
content-hash = "46a2bfaab3735c239d54104de31ba29e2f4aca02d88e780577e07e57e3a07a79"
|
||||
@@ -0,0 +1,16 @@
|
||||
[tool.poetry]
|
||||
name = "lem-dcbm-api-mock"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Fabian Klemm <fabian.klemm@pionix.de>"]
|
||||
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"
|
||||
Reference in New Issue
Block a user