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:
Eric F
2026-06-08 00:38:27 -04:00
parent 468cfeaa50
commit d398a6ced2
7326 changed files with 1177561 additions and 7 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>`_

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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.

View File

@@ -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()

View File

@@ -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

View File

@@ -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"])

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,4 @@
[pytest]
log_cli=true
log_level=debug
asyncio_mode=strict

View File

@@ -0,0 +1,8 @@
settings:
telemetry_enabled: true
active_modules:
lem_dcbm_controller:
config_module:
ip_address: "localhost"
port: 8000
module: LemDCBM400600

View File

@@ -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

View File

@@ -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}"

View File

@@ -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)

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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()

View 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"

View File

@@ -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"