Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter
- CitrineOS core extracted (CSMS OCPP 2.0.1) - OpenOCPP extracted (firmware OCPP 1.6J/2.0.1) - ShapeShifter library installed (pip install -e) - ShapeShifter specification extracted - EVerest extracted TODO updated with progress
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
set(TEST_TARGET_NAME ${PROJECT_NAME}_lem_dcbm_400600_controller_tests)
|
||||
|
||||
set(MODULE_DIR "${PROJECT_SOURCE_DIR}/modules/HardwareDrivers/PowerMeters/LemDCBM400600")
|
||||
|
||||
add_executable(${TEST_TARGET_NAME}
|
||||
test_lem_dcbm_400600_controller.cpp
|
||||
"${MODULE_DIR}/main/lem_dcbm_400600_controller.cpp"
|
||||
"${MODULE_DIR}/main/lem_dcbm_time_sync_helper.cpp"
|
||||
)
|
||||
add_dependencies(${TEST_TARGET_NAME} ${MODULE_NAME})
|
||||
|
||||
set(INCLUDE_DIR
|
||||
"main"
|
||||
"tests"
|
||||
"${MODULE_DIR}/main"
|
||||
"${MODULE_DIR}/tests")
|
||||
|
||||
get_target_property(GENERATED_INCLUDE_DIR generate_cpp_files EVEREST_GENERATED_INCLUDE_DIR)
|
||||
|
||||
target_include_directories(${TEST_TARGET_NAME} PRIVATE
|
||||
tests
|
||||
${INCLUDE_DIR}
|
||||
${GENERATED_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
#
|
||||
target_link_libraries(${TEST_TARGET_NAME} PRIVATE
|
||||
GTest::gmock_main
|
||||
everest::timer
|
||||
everest::framework
|
||||
nlohmann_json::nlohmann_json
|
||||
CURL::libcurl
|
||||
)
|
||||
|
||||
|
||||
add_test(${TEST_TARGET_NAME} ${TEST_TARGET_NAME})
|
||||
ev_register_test_target(${TEST_TARGET_NAME})
|
||||
|
||||
## Time sync helper test
|
||||
set(TEST_TARGET_NAME ${PROJECT_NAME}_lem_time_sync_helper_tests)
|
||||
add_executable(${TEST_TARGET_NAME}
|
||||
test_lem_dcbm_time_sync_helper.cpp
|
||||
"${MODULE_DIR}/main/lem_dcbm_time_sync_helper.cpp"
|
||||
)
|
||||
|
||||
add_dependencies(${TEST_TARGET_NAME} ${MODULE_NAME})
|
||||
|
||||
set(INCLUDE_DIR
|
||||
"main"
|
||||
"tests"
|
||||
"${MODULE_DIR}/main"
|
||||
"${MODULE_DIR}/tests")
|
||||
|
||||
get_target_property(GENERATED_INCLUDE_DIR generate_cpp_files EVEREST_GENERATED_INCLUDE_DIR)
|
||||
|
||||
target_include_directories(${TEST_TARGET_NAME} PRIVATE
|
||||
tests
|
||||
${INCLUDE_DIR}
|
||||
${GENERATED_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
#
|
||||
target_link_libraries(${TEST_TARGET_NAME} PRIVATE
|
||||
GTest::gmock_main
|
||||
everest::timer
|
||||
everest::framework
|
||||
nlohmann_json::nlohmann_json
|
||||
CURL::libcurl
|
||||
)
|
||||
|
||||
add_test(${TEST_TARGET_NAME} ${TEST_TARGET_NAME})
|
||||
ev_register_test_target(${TEST_TARGET_NAME})
|
||||
|
||||
## Temperature monitor test
|
||||
set(TEST_TARGET_NAME ${PROJECT_NAME}_lem_temperature_monitor_tests)
|
||||
add_executable(${TEST_TARGET_NAME}
|
||||
test_temperature_monitor.cpp
|
||||
)
|
||||
|
||||
add_dependencies(${TEST_TARGET_NAME} ${MODULE_NAME})
|
||||
|
||||
set(INCLUDE_DIR
|
||||
"main"
|
||||
"tests"
|
||||
"${MODULE_DIR}/main"
|
||||
"${MODULE_DIR}/tests")
|
||||
|
||||
get_target_property(GENERATED_INCLUDE_DIR generate_cpp_files EVEREST_GENERATED_INCLUDE_DIR)
|
||||
|
||||
target_include_directories(${TEST_TARGET_NAME} PRIVATE
|
||||
tests
|
||||
${INCLUDE_DIR}
|
||||
${GENERATED_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(${TEST_TARGET_NAME} PRIVATE
|
||||
GTest::gmock_main
|
||||
everest::timer
|
||||
everest::framework
|
||||
nlohmann_json::nlohmann_json
|
||||
CURL::libcurl
|
||||
)
|
||||
|
||||
add_test(${TEST_TARGET_NAME} ${TEST_TARGET_NAME})
|
||||
ev_register_test_target(${TEST_TARGET_NAME})
|
||||
|
||||
## http client integration client
|
||||
|
||||
add_executable(integration_test_http_client
|
||||
integration_test_http_client.cpp
|
||||
"${MODULE_DIR}/main/http_client.cpp"
|
||||
)
|
||||
|
||||
target_include_directories(integration_test_http_client PRIVATE
|
||||
tests
|
||||
${INCLUDE_DIR}
|
||||
${GENERATED_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
|
||||
target_link_libraries(integration_test_http_client PRIVATE
|
||||
GTest::gmock_main
|
||||
everest::timer
|
||||
everest::framework
|
||||
nlohmann_json::nlohmann_json
|
||||
CURL::libcurl
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
## Unit & Integration Tests with GTest
|
||||
|
||||
A series of unit tests test the implemented business logic of the controller. For the http client that wraps
|
||||
libcurl, integration tests can be used to test succesful communication with the device.
|
||||
|
||||
### Requirements for unit/integration tests
|
||||
|
||||
The GTest unit tests require GTest. This can be installed via
|
||||
```bash
|
||||
apt install libgtest-dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
Build the module with the flag `LEMDCBM_BUILD_TESTS:BOOL=ON`, e.g. via
|
||||
|
||||
```bash
|
||||
export CMAKE_PREFIX_PATH=<Path to workspace / required Everest repositories>
|
||||
mkdir -p build
|
||||
cd build
|
||||
cmake -DLEMDCBM_BUILD_TESTS:BOOL=ON ..
|
||||
make -j 10
|
||||
```
|
||||
|
||||
### Run Unit tests
|
||||
|
||||
In the build directory, run
|
||||
```bash
|
||||
./modules/LemDCBM400600/tests/test_lem_dcbm_400600_controller
|
||||
```
|
||||
|
||||
### Run HTTPClient Integration Tests
|
||||
|
||||
Note: The integration test require the configured backend (in form of an actual LEM DCBM oder the Mock) to be running
|
||||
at the configured address and port.
|
||||
|
||||
To start the mocked API, run
|
||||
```bash
|
||||
python3 <Projekt root directory>/modules/LemDCBM400600/utils/lem_dcbm_api_mock/main.py
|
||||
```
|
||||
|
||||
To then run the http client integration tests, run in the build directory
|
||||
```bash
|
||||
./modules/LemDCBM400600/tests/integration_test_http_client
|
||||
```
|
||||
## Integration / E2E Tests for LemDCBM400600 (Python wrapped)
|
||||
|
||||
The integration / E2E tests built on the integration test tools from `EVerest/tests` allow to test
|
||||
the module from inside EVerest both against the mock (integration test) and the actual device (e2e test).
|
||||
|
||||
### Requirements for E2E tests
|
||||
|
||||
- Module built & installed into <Build dir>/dist
|
||||
|
||||
- Everest testing utils installed; cf. everst-core/tests/Readme.md
|
||||
|
||||
- Further, this requires the following installed packages in the used Python interpreter
|
||||
```bash
|
||||
pip install fastapi uvicorn pyyaml
|
||||
```
|
||||
|
||||
If not done before set the Cmake install prefix, for example via
|
||||
```bash
|
||||
CMAKE_INSTALL_PREFIX=<Build dir>/dist
|
||||
```
|
||||
then build and install the tool again (`cmake build`, `make`, `make install`; in the end, the $CMAKE_INSTALL_PREFIX directory
|
||||
should contain the installed binaries)
|
||||
|
||||
### Run E2E tests
|
||||
|
||||
In `modules/LemDCBM400600/tests`, run:
|
||||
```bash
|
||||
python3 -m pytest --everest-prefix=$CMAKE_INSTALL_PREFIX test_lem_dcbm_400_600_sil.py
|
||||
python3 -m pytest --lem-dcbm-host 10.8.8.24 --lem-dcbm-port 5566 --everest-prefix=$CMAKE_INSTALL_PREFIX test_lem_dcbm_400_600_e2e.py
|
||||
```
|
||||
(here, for the e2e test substitute appropriate values of the actual test device)
|
||||
|
||||
*Note* Due to a behavior of the `EverestCore` testing class from everest-utils, it is not possible
|
||||
to quote escape strings in configuration yamls; this leads to an unexpected behavior since an unquoted
|
||||
ip address (such as 127.0.0.1) will fail the EVerest type check. A local workaround is a
|
||||
host entry in `/etc/hosts`, such as
|
||||
```bash
|
||||
10.8.8.24 lemdcbm
|
||||
```
|
||||
and then use `lemdcbm` instead of the IP address.
|
||||
@@ -0,0 +1,45 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2020 - 2023 Pionix GmbH and Contributors to EVerest
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.append(str(Path(__file__).parent / "../utils"))
|
||||
|
||||
from lem_dcbm_api_mock.main import app as lem_api_mock
|
||||
import uvicorn
|
||||
from multiprocessing import Process
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption("--everest-prefix", action="store", default="../build/dist",
|
||||
help="everest prefix path; default = '../build/dist'")
|
||||
parser.addoption("--lem-dcbm-host", action="store",
|
||||
help="Address of LEM DCBM 400/600")
|
||||
parser.addoption("--lem-dcbm-port", action="store",
|
||||
help="Port of LEM DCBM 400/600")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def lem_dcbm_mock():
|
||||
# Start the server in a subprocess
|
||||
server = Process(target=uvicorn.run,
|
||||
args=(lem_api_mock,),
|
||||
kwargs={
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000,
|
||||
"log_level": "info"},
|
||||
daemon=True)
|
||||
try:
|
||||
server.start()
|
||||
time.sleep(0.1) # Allow some time for the server to start
|
||||
assert server.is_alive()
|
||||
logging.info("started up lem dcbm api mock server")
|
||||
yield # This is where the testing happens
|
||||
|
||||
# After the tests, terminate the server
|
||||
finally:
|
||||
server.terminate()
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#include "http_client.hpp"
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace module::main {
|
||||
|
||||
class HttpClientIntegrationTest : public ::testing::Test {};
|
||||
|
||||
static const std::string HOST = "localhost";
|
||||
static int HTTP_PORT = 8000;
|
||||
static int HTTPS_PORT = 8443;
|
||||
const char* MOCK_API_TLS_CERT_CONTENTS = "MIIDazCCAlOgAwIBAgIUHDu1ZdpL229xmwqrmq/oq9YQaYwwDQYJKoZIhvcNAQEL"
|
||||
"BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM"
|
||||
"GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA5MTQxMjAxMDhaFw0yNDA5"
|
||||
"MTMxMjAxMDhaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw"
|
||||
"HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB"
|
||||
"AQUAA4IBDwAwggEKAoIBAQDCES0SIQSMzKi6aIuLNkjXUj1/eGjuAV2qLcPiaRe3"
|
||||
"GYRy+tDS4wJb0JxdU2JYMzGrq3tNGcm6E/bXpkJWjB1znFhd+6wr077KV+ryMfBa"
|
||||
"QwE7uIOnj4XeIBRhU9QvgSF3vfvoxOEKsq6X+cXPlAelbnMrIXniL4lwLNJD2UAl"
|
||||
"eNYmJFKIJfZPmnNKLQwZkvIL8H5G134KMvOh2AVG1EHuzUBoKs72d77TI6UsITu9"
|
||||
"/PeATVxm9hhRpk1tuq/NLoUHTqgUPsfN83zeCm7buOOmQpJsypFz5lVmLmtq7YY3"
|
||||
"+vu4+IuUQegJUC+eXH+WXrh1gkCpNre/S7KgS0FWnJD3AgMBAAGjUzBRMB0GA1Ud"
|
||||
"DgQWBBQ/YFwElfxomN+kQvtf4tTjU4XGrzAfBgNVHSMEGDAWgBQ/YFwElfxomN+k"
|
||||
"Qvtf4tTjU4XGrzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAZ"
|
||||
"K9/4szwsoeTQbPxeDNKNeBRrdHhVOtC3PLP2O0eqZkogTFE4PhreL7S+Q4INbrUh"
|
||||
"Pw/mZ9FwfsyHVupJWUBgPZx9kSflAJHFG7rikY13UenLmYNU4lGsoJQEewLw+wT1"
|
||||
"jfJgW/LXZ2He1dMsp3IVyNjR62BtZyI4B9ArUxyILpSSsczk7XN4oEkWDCTATP7t"
|
||||
"VfsKaM6eIfSnY11g1koVjGy+YtdcO5GJ/6Q7va1BuT3PzD3GjcxPZfhVu3rJBupl"
|
||||
"0p0LoiBSxpcepMYag5zguxoyU78FKdShFyl5lnFUtAWVD9Hi1+M/znYwiXpS6EGc"
|
||||
"DW+bAzWAH3M1KKV2UUTa";
|
||||
const std::string MOCK_API_TLS_CERT_NO_NEWLINES =
|
||||
std::string("-----BEGIN CERTIFICATE-----") + MOCK_API_TLS_CERT_CONTENTS + "-----END CERTIFICATE-----";
|
||||
const std::string MOCK_API_TLS_CERT_BOTH_NEWLINES =
|
||||
std::string("-----BEGIN CERTIFICATE-----\n") + MOCK_API_TLS_CERT_CONTENTS + "\n-----END CERTIFICATE-----";
|
||||
const std::string MOCK_API_TLS_CERT_FIRST_NEWLINE =
|
||||
std::string("-----BEGIN CERTIFICATE-----\n") + MOCK_API_TLS_CERT_CONTENTS + "-----END CERTIFICATE-----";
|
||||
const std::string MOCK_API_TLS_CERT_SECOND_NEWLINE =
|
||||
std::string("-----BEGIN CERTIFICATE-----") + MOCK_API_TLS_CERT_CONTENTS + "\n-----END CERTIFICATE-----";
|
||||
|
||||
// \brief Test get_powermeter returns correct its status including meter_Id
|
||||
TEST_F(HttpClientIntegrationTest, test_status) {
|
||||
|
||||
HttpClient client(HOST, HTTP_PORT, "");
|
||||
auto res = client.get("/v1/status");
|
||||
|
||||
EXPECT_EQ(200, res.status_code);
|
||||
|
||||
auto json = nlohmann::json::parse(res.body);
|
||||
|
||||
EXPECT_THAT(json.at("meterId").size(), testing::Gt(0));
|
||||
}
|
||||
|
||||
/// \brief Test get_powermeter returns correct live measure
|
||||
TEST_F(HttpClientIntegrationTest, test_get_livemeasure) {
|
||||
|
||||
HttpClient client(HOST, HTTP_PORT, "");
|
||||
auto res = client.get("/v1/livemeasure");
|
||||
|
||||
EXPECT_EQ(200, res.status_code);
|
||||
|
||||
auto json = nlohmann::json::parse(res.body);
|
||||
|
||||
EXPECT_THAT(json.at("timestamp").size(), testing::Gt(0));
|
||||
}
|
||||
|
||||
TEST_F(HttpClientIntegrationTest, test_put_legal) {
|
||||
|
||||
HttpClient client(HOST, HTTP_PORT, "");
|
||||
auto res = client.put("/v1/legal?transactionId=test_transaction", R"({"running": false})");
|
||||
|
||||
EXPECT_EQ(200, res.status_code);
|
||||
|
||||
auto json = nlohmann::json::parse(res.body);
|
||||
|
||||
EXPECT_EQ(json.at("transactionId"), "test_transaction");
|
||||
}
|
||||
|
||||
TEST_F(HttpClientIntegrationTest, test_post_legal) {
|
||||
|
||||
HttpClient client(HOST, HTTP_PORT, "");
|
||||
auto res = client.post("/v1/legal", R"({
|
||||
"evseId": "string",
|
||||
"transactionId": "test_transaction",
|
||||
"clientId": "string",
|
||||
"tariffId": 0,
|
||||
"cableId": 0,
|
||||
"userData": "string"
|
||||
})");
|
||||
|
||||
EXPECT_EQ(201, res.status_code);
|
||||
|
||||
auto json = nlohmann::json::parse(res.body);
|
||||
|
||||
EXPECT_EQ(json.at("transactionId"), "test_transaction");
|
||||
EXPECT_EQ(json.at("running").get<bool>(), true);
|
||||
}
|
||||
|
||||
/// \brief Test get_powermeter returns correct live measure
|
||||
TEST_F(HttpClientIntegrationTest, test_get_livemeasure_tls) {
|
||||
|
||||
HttpClient client(HOST, HTTPS_PORT, MOCK_API_TLS_CERT_BOTH_NEWLINES);
|
||||
auto res = client.get("/v1/livemeasure");
|
||||
|
||||
EXPECT_EQ(200, res.status_code);
|
||||
|
||||
auto json = nlohmann::json::parse(res.body);
|
||||
|
||||
EXPECT_THAT(json.at("timestamp").size(), testing::Gt(0));
|
||||
}
|
||||
|
||||
TEST_F(HttpClientIntegrationTest, test_put_legal_tls) {
|
||||
|
||||
HttpClient client(HOST, HTTPS_PORT, MOCK_API_TLS_CERT_BOTH_NEWLINES);
|
||||
auto res = client.put("/v1/legal?transactionId=test_transaction", R"({"running": false})");
|
||||
|
||||
EXPECT_EQ(200, res.status_code);
|
||||
|
||||
auto json = nlohmann::json::parse(res.body);
|
||||
|
||||
EXPECT_EQ(json.at("transactionId"), "test_transaction");
|
||||
}
|
||||
|
||||
TEST_F(HttpClientIntegrationTest, test_post_legal_tls) {
|
||||
|
||||
HttpClient client(HOST, HTTPS_PORT, MOCK_API_TLS_CERT_BOTH_NEWLINES);
|
||||
auto res = client.post("/v1/legal", R"({
|
||||
"evseId": "string",
|
||||
"transactionId": "test_transaction",
|
||||
"clientId": "string",
|
||||
"tariffId": 0,
|
||||
"cableId": 0,
|
||||
"userData": "string"
|
||||
})");
|
||||
|
||||
EXPECT_EQ(201, res.status_code);
|
||||
|
||||
auto json = nlohmann::json::parse(res.body);
|
||||
|
||||
EXPECT_EQ(json.at("transactionId"), "test_transaction");
|
||||
EXPECT_EQ(json.at("running").get<bool>(), true);
|
||||
}
|
||||
|
||||
class HttpClientIntegrationTestWithCert : public ::testing::TestWithParam<std::string> {
|
||||
protected:
|
||||
std::string cert;
|
||||
};
|
||||
|
||||
/// \brief Test that the module fixes missing newlines correctly
|
||||
TEST_P(HttpClientIntegrationTestWithCert, test_fix_missing_newlines_in_cert) {
|
||||
std::string cert = GetParam();
|
||||
HttpClient client(HOST, HTTPS_PORT, cert);
|
||||
auto res = client.get("/v1/livemeasure");
|
||||
EXPECT_EQ(200, res.status_code);
|
||||
}
|
||||
|
||||
INSTANTIATE_TEST_SUITE_P(HttpFixCertNewlinesTests, HttpClientIntegrationTestWithCert,
|
||||
::testing::Values(MOCK_API_TLS_CERT_NO_NEWLINES, MOCK_API_TLS_CERT_FIRST_NEWLINE,
|
||||
MOCK_API_TLS_CERT_SECOND_NEWLINE));
|
||||
} // namespace module::main
|
||||
@@ -0,0 +1,138 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import ssl
|
||||
from datetime import datetime, timezone
|
||||
from http.client import HTTPSConnection
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DCBMInterface:
|
||||
|
||||
def __init__(self, host: str, port: int, enable_tls=False):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.enable_tls = enable_tls
|
||||
|
||||
@property
|
||||
def _base_url(self) -> str:
|
||||
return f"{'https' if self.enable_tls else 'http'}://{self.host}:{self.port}"
|
||||
|
||||
async def wait_for_status(self, target_status: int, timeout=5, ) -> int:
|
||||
async def check():
|
||||
while requests.get(f"{self._base_url}/v1/status", verify=False).json()["status"][
|
||||
"value"] != target_status:
|
||||
await asyncio.sleep(1)
|
||||
return
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(check(), timeout)
|
||||
return True
|
||||
except asyncio.exceptions.TimeoutError:
|
||||
return False
|
||||
|
||||
def activate_tls_via_http(self):
|
||||
if self._is_https_running():
|
||||
return
|
||||
logging.info("enabling tls on dcbm device")
|
||||
res = requests.put(f"http://{self.host}:{self.port}/v1/settings", json={
|
||||
"http": {
|
||||
"tls_on": True,
|
||||
}
|
||||
})
|
||||
assert res.ok
|
||||
|
||||
def deactivate_tls_via_https(self):
|
||||
if not self._is_https_running():
|
||||
return
|
||||
logging.info("disabling tls on dcbm device")
|
||||
res = requests.put(f"https://{self.host}:{self.port}/v1/settings", json={
|
||||
"http": {
|
||||
"tls_on": False
|
||||
}
|
||||
}, verify=False)
|
||||
assert res.ok
|
||||
|
||||
def _is_https_running(self) -> bool:
|
||||
try:
|
||||
HTTPS_URL = urlparse(f"https://{self.host}:{self.port}")
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
ctx.set_ciphers('ALL:@SECLEVEL=1')
|
||||
connection = HTTPSConnection(HTTPS_URL.netloc, timeout=2, context=ctx)
|
||||
connection.request('HEAD', HTTPS_URL.path)
|
||||
if connection.getresponse():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
def stop_any_ongoing_transaction(self):
|
||||
ongoing = requests.get(f"{self._base_url}/v1/status", verify=False).json()["status"]["bits"][
|
||||
"transactionIsOnGoing"]
|
||||
if ongoing:
|
||||
transaction = requests.get(f"{self._base_url}/v1/legal", verify=False).json()["transactionId"]
|
||||
logging.warning(f"stopping transaction {transaction}")
|
||||
stop_res = requests.put(f"{self._base_url}/v1/legal?transactionId={transaction}", verify=False,
|
||||
json={
|
||||
"running": False
|
||||
})
|
||||
assert stop_res.ok
|
||||
asyncio.run(self.wait_for_status(17))
|
||||
|
||||
def reset_device(self):
|
||||
""" Reset to http; stop any ongoing transaction
|
||||
"""
|
||||
logging.info("reset DCBM device settings to http; stopping any transaction")
|
||||
try:
|
||||
self.deactivate_tls_via_https()
|
||||
except:
|
||||
pass
|
||||
self.stop_any_ongoing_transaction()
|
||||
self.disable_ntp()
|
||||
self.set_time(datetime.utcnow())
|
||||
|
||||
def get_certificate(self) -> str:
|
||||
return requests.get(f"http://{self.host}:{self.port}/v1/certificate").json()["certificate"]
|
||||
|
||||
class DCBMStatus(BaseModel):
|
||||
status: dict
|
||||
time: datetime
|
||||
|
||||
def get_status(self) -> DCBMStatus:
|
||||
return self.DCBMStatus(**requests.get(f"{self._base_url}/v1/status", verify=False).json())
|
||||
|
||||
def set_time(self, time: datetime):
|
||||
assert requests.put(f"{self._base_url}/v1/settings", verify=False,
|
||||
json={"time": {
|
||||
"utc": time.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
}}).ok
|
||||
|
||||
def disable_ntp(self):
|
||||
|
||||
assert requests.put(f"{self._base_url}/v1/settings", verify=False,
|
||||
json={"ntp": {
|
||||
"servers": [
|
||||
{
|
||||
"ipAddress": "",
|
||||
"port": 123
|
||||
},
|
||||
{
|
||||
"ipAddress": "",
|
||||
"port": 123
|
||||
}
|
||||
],
|
||||
"ntpActivated": False}
|
||||
}).ok
|
||||
|
||||
class DCBMNtpSettings(BaseModel):
|
||||
servers: list[dict]
|
||||
ntpActivated: bool
|
||||
|
||||
def get_ntp_settings(self) -> DCBMNtpSettings:
|
||||
return self.DCBMNtpSettings(**requests.get(f"{self._base_url}/v1/settings", verify=False,
|
||||
).json()["ntp"])
|
||||
@@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import yaml
|
||||
from everest.framework import RuntimeSession
|
||||
from everest.testing.core_utils.everest_core import EverestCore, Requirement
|
||||
from pydantic import BaseModel, Extra, Field
|
||||
|
||||
from lem_dcbm_test_utils.probe_module import ProbeModule
|
||||
|
||||
|
||||
class LecmDDCBMModuleConfig(BaseModel):
|
||||
ip_address: str
|
||||
port: int
|
||||
meter_tls_certificate: str | None = None
|
||||
ntp_server_1_ip_addr: str | None = None
|
||||
ntp_server_1_port: int | None = None
|
||||
ntp_server_2_ip_addr: str | None = None
|
||||
ntp_server_2_port: int | None = None
|
||||
|
||||
class StartTransactionSuccessResponse(BaseModel, extra=Extra.forbid):
|
||||
status: str = Field("OK", const=True, strict=True)
|
||||
transaction_max_stop_time: datetime
|
||||
transaction_min_stop_time: datetime
|
||||
|
||||
|
||||
class StopTransactionSuccessResponse(BaseModel, extra=Extra.allow):
|
||||
status: str = Field("OK", const=True, strict=True)
|
||||
|
||||
|
||||
class LemDCBMStandaloneEverestInstance(contextlib.ContextDecorator):
|
||||
|
||||
def __init__(self, everest_prefix: Path, config: LecmDDCBMModuleConfig):
|
||||
self.everest_prefix = everest_prefix
|
||||
self.config = config
|
||||
self._stack = None
|
||||
self._stack = None
|
||||
|
||||
|
||||
def _write_config(self, target_file: Path):
|
||||
template_file = Path(__file__).parent / "../resources/config-standalone-lemdcbm400600.yaml"
|
||||
config = yaml.safe_load(template_file.read_text(encoding="utf-8"))
|
||||
module_config = config["active_modules"]["lem_dcbm_controller"]["config_module"] = {
|
||||
**config["active_modules"]["lem_dcbm_controller"]["config_module"],
|
||||
**self.config.dict(exclude_none=True)
|
||||
}
|
||||
|
||||
logging.info(f"writing config ip_address={self.config.ip_address} port={self.config.port} into {target_file}")
|
||||
with target_file.open("w") as f:
|
||||
yaml.dump(config, f)
|
||||
|
||||
def __enter__(self):
|
||||
self._stack = contextlib.ExitStack()
|
||||
file = Path(self._stack.enter_context(NamedTemporaryFile()).name)
|
||||
self._write_config(file)
|
||||
self._everest = EverestCore(self.everest_prefix, file)
|
||||
self._everest.start(standalone_module='probe', test_connections={
|
||||
'test_control': [Requirement('lem_dcbm_controller', 'main')]
|
||||
})
|
||||
if self._everest.status_listener.wait_for_status(3, ["ALL_MODULES_STARTED"]):
|
||||
self._everest.all_modules_started_event.set()
|
||||
logging.info("set all modules started event...")
|
||||
self._probe_module = self._create_probe_module()
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self._everest.stop()
|
||||
self._stack.__exit__(*exc)
|
||||
self._stack = None
|
||||
self._everest = None
|
||||
self._probe_module = None
|
||||
return False
|
||||
|
||||
@property
|
||||
def probe_module(self) -> ProbeModule:
|
||||
assert self._probe_module
|
||||
return self._probe_module
|
||||
|
||||
def _get_session(self) -> RuntimeSession:
|
||||
assert self._everest
|
||||
return RuntimeSession(str(self._everest.prefix_path),
|
||||
str(self._everest.everest_config_path))
|
||||
|
||||
def _create_probe_module(self) -> ProbeModule:
|
||||
session = self._get_session()
|
||||
module = ProbeModule(session)
|
||||
asyncio.run(module.wait_to_be_ready())
|
||||
return module
|
||||
@@ -0,0 +1,48 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from asyncio.queues import Queue
|
||||
from typing import Any
|
||||
|
||||
from everest.framework import Module, RuntimeSession
|
||||
|
||||
from lem_dcbm_test_utils.types import Powermeter
|
||||
|
||||
|
||||
class ProbeModule:
|
||||
def __init__(self, session: RuntimeSession):
|
||||
logging.info("ProbeModule init start")
|
||||
m = Module('probe', session)
|
||||
self._setup = m.say_hello()
|
||||
self._mod = m
|
||||
|
||||
# subscribe to session events
|
||||
logging.info(self._setup.connections)
|
||||
evse_manager_ff = self._setup.connections['test_control'][0]
|
||||
self._mod.subscribe_variable(evse_manager_ff, 'powermeter',
|
||||
self._handle_evse_manager_powermeter_message)
|
||||
|
||||
self._msg_queue = Queue()
|
||||
self._ready_event = asyncio.Event()
|
||||
m.init_done(self._ready)
|
||||
logging.info("ProbeModule init done")
|
||||
|
||||
def _ready(self):
|
||||
logging.info("ProbeModule ready")
|
||||
self._ready_event.set()
|
||||
|
||||
def _handle_evse_manager_powermeter_message(self, message):
|
||||
asyncio.run(self._msg_queue.put(message))
|
||||
|
||||
async def poll_next_powermeter(self, timeout) -> Powermeter:
|
||||
return Powermeter(**(await asyncio.wait_for(self._msg_queue.get(), timeout=timeout)))
|
||||
|
||||
def call_powermeter_command(self, command_name: str, args: dict) -> Any:
|
||||
lem_ff = self._setup.connections['test_control'][0]
|
||||
try:
|
||||
return self._mod.call_command(lem_ff, command_name, args)
|
||||
except Exception as e:
|
||||
logging.info(f"Exception in calling command {command_name}: {type(e)}: {e}")
|
||||
raise e
|
||||
|
||||
async def wait_to_be_ready(self, timeout=3):
|
||||
await asyncio.wait_for(self._ready_event.wait(), timeout)
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Extra
|
||||
|
||||
|
||||
class UnitCurrent(BaseModel, extra=Extra.forbid):
|
||||
DC: float | None
|
||||
L1: float | None
|
||||
L2: float | None
|
||||
L3: float | None
|
||||
N: float | None
|
||||
|
||||
|
||||
class UnitVoltage(BaseModel, extra=Extra.forbid):
|
||||
DC: float | None
|
||||
L1: float | None
|
||||
L2: float | None
|
||||
L3: float | None
|
||||
|
||||
|
||||
class UnitFrequency(BaseModel, extra=Extra.forbid):
|
||||
L1: float | None
|
||||
L2: float | None
|
||||
L3: float | None
|
||||
|
||||
|
||||
class UnitPower(BaseModel, extra=Extra.forbid):
|
||||
total: float
|
||||
L1: float | None
|
||||
L2: float | None
|
||||
L3: float | None
|
||||
|
||||
|
||||
class UnitEnergy(BaseModel, extra=Extra.forbid):
|
||||
total: float
|
||||
L1: float | None
|
||||
L2: float | None
|
||||
L3: float | None
|
||||
|
||||
|
||||
class Powermeter(BaseModel, extra=Extra.forbid):
|
||||
current_A: UnitCurrent
|
||||
energy_Wh_export: UnitEnergy
|
||||
energy_Wh_import: UnitEnergy
|
||||
meter_id: str
|
||||
power_W: UnitPower
|
||||
timestamp: datetime
|
||||
voltage_V: UnitVoltage
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
log_cli=true
|
||||
log_level=debug
|
||||
asyncio_mode=strict
|
||||
@@ -0,0 +1,8 @@
|
||||
settings:
|
||||
telemetry_enabled: true
|
||||
active_modules:
|
||||
lem_dcbm_controller:
|
||||
config_module:
|
||||
ip_address: "localhost"
|
||||
port: 8000
|
||||
module: LemDCBM400600
|
||||
@@ -0,0 +1,651 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
// Unit tests for the LemDCBM400600Controller
|
||||
|
||||
#include "http_client_interface.hpp"
|
||||
#include "lem_dcbm_400600_controller.hpp"
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace module::main {
|
||||
|
||||
class HTTPClientMock : public HttpClientInterface {
|
||||
public:
|
||||
MOCK_METHOD(HttpResponse, get, (const std::string& path), (override, const));
|
||||
MOCK_METHOD(HttpResponse, post, (const std::string& path, const std::string& body), (override, const));
|
||||
MOCK_METHOD(HttpResponse, put, (const std::string& path, const std::string& body), (override, const));
|
||||
};
|
||||
|
||||
class LemDCBMTimeSyncHelperMock : public LemDCBMTimeSyncHelper {
|
||||
public:
|
||||
MOCK_METHOD(void, sync_if_deadline_expired, (const HttpClientInterface& httpClient), (override));
|
||||
MOCK_METHOD(void, sync, (const HttpClientInterface& httpClient), (override));
|
||||
MOCK_METHOD(void, restart_unsafe_period, (), (override));
|
||||
LemDCBMTimeSyncHelperMock() : LemDCBMTimeSyncHelper({}, {}){};
|
||||
};
|
||||
|
||||
// Fixture class providing
|
||||
// - a http client mock
|
||||
// - default responses & request objects
|
||||
class LemDCBM400600ControllerTest : public ::testing::Test {
|
||||
|
||||
protected:
|
||||
std::unique_ptr<HTTPClientMock> http_client;
|
||||
std::unique_ptr<LemDCBMTimeSyncHelperMock> time_sync_helper;
|
||||
|
||||
const std::string livemeasure_response{R"({
|
||||
"voltage": 4.2,
|
||||
"current": 4,
|
||||
"power": 3,
|
||||
"temperatureH": 0,
|
||||
"temperatureL": 0,
|
||||
"energyImportTotal": 1,
|
||||
"energyExportTotal": 2,
|
||||
"timestamp": "2023-09-10T21:10:08.068773"
|
||||
})"};
|
||||
|
||||
const types::powermeter::TransactionReq transaction_request{
|
||||
"mock_evse_id",
|
||||
"mock_transaction_id",
|
||||
types::powermeter::OCMFUserIdentificationStatus::ASSIGNED,
|
||||
{},
|
||||
types::powermeter::OCMFIdentificationType::ISO14443,
|
||||
std::nullopt,
|
||||
std::nullopt,
|
||||
std::nullopt};
|
||||
|
||||
const std::string expected_start_transaction_request_body{
|
||||
R"({"evseId":"mock_evse_id","transactionId":"mock_transaction_id","clientId":",mock_transaction_id","tariffId":0,"cableId":0,"userData":""})"};
|
||||
|
||||
const std::string put_legal_response = R"({
|
||||
"paginationCounter": 6,
|
||||
"transactionId": "mock_transaction_id",
|
||||
"evseId": "+49*DEF*E123ABC",
|
||||
"clientId": "C12",
|
||||
"tariffId": 2,
|
||||
"cableSp": {
|
||||
"cableSpName": "2mR_Comp",
|
||||
"cableSpId": 1,
|
||||
"cableSpRes": 2
|
||||
},
|
||||
"userData": "",
|
||||
"meterValue": {
|
||||
"timestampStart": "2020-12-10T16:39:15+01:00",
|
||||
"timestampStop": "2020-12-10T16:39:15+01:00",
|
||||
"transactionDuration": 70,
|
||||
"intermediateRead": false,
|
||||
"transactionStatus": 17,
|
||||
"sampleValue": {
|
||||
"energyUnit": "kWh",
|
||||
"energyImport": 7.637,
|
||||
"energyImportTotalStart": 188.977,
|
||||
"energyImportTotalStop": 196.614,
|
||||
"energyExport": 0.000,
|
||||
"energyExportTotalStart": 0.000,
|
||||
"energyExportTotalStop": 0.000
|
||||
}},
|
||||
"meterId": "12024072805",
|
||||
"signature": "304502203DC38FBC722D216568D6ECB4B352577A999B6D184EA6AD48BDCAE7766DB1D628022100A7687B4CB5573829D407DD4B17D41C297917B7E8307E5017711B5A3A987F6801",
|
||||
"publicKey": "A80F10D968E1122F8820F288B23C4E1C0DA912F35B48481274ADFEFE66D7E87E130C7CF2B8047C45CF105041C8C3A57DD242782F755C9443F42DABA9404A67BF"
|
||||
})";
|
||||
|
||||
// IT = -1 so that init() does not call set_identification_type()
|
||||
const LemDCBM400600Controller::Conf controller_config{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, -1};
|
||||
|
||||
void SetUp() override {
|
||||
this->http_client = std::make_unique<HTTPClientMock>();
|
||||
this->time_sync_helper = std::make_unique<LemDCBMTimeSyncHelperMock>();
|
||||
}
|
||||
};
|
||||
|
||||
// Extended fixture for parametrizing tests for invalid response checks
|
||||
class LemDCBM400600ControllerTestInvalidResponses
|
||||
: public LemDCBM400600ControllerTest,
|
||||
public ::testing::WithParamInterface<testing::internal::ReturnAction<HttpResponse>> {};
|
||||
|
||||
//****************************************************************
|
||||
// Test get_powermeter behavior
|
||||
|
||||
/// \brief Test get_powermeter returns correct live measure
|
||||
TEST_F(LemDCBM400600ControllerTest, test_get_powermeter) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, get("/v1/livemeasure"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->livemeasure_response}));
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
|
||||
// Act
|
||||
const types::powermeter::Powermeter& powermeter = controller.get_powermeter();
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(powermeter.timestamp, "2023-09-10T21:10:08.068773");
|
||||
EXPECT_THAT(powermeter.energy_Wh_import.total, testing::FloatEq(1000.0));
|
||||
EXPECT_THAT(powermeter.energy_Wh_export->total, testing::FloatEq(2000.0));
|
||||
EXPECT_THAT(powermeter.power_W->total, testing::FloatEq(3000.0));
|
||||
EXPECT_THAT(powermeter.current_A->DC.value(), testing::FloatEq(4.0));
|
||||
EXPECT_THAT(powermeter.voltage_V->DC.value(), testing::FloatEq(4.2));
|
||||
EXPECT_THAT(powermeter.meter_id.value(), ""); // not initialized
|
||||
}
|
||||
|
||||
/// \brief Test get_powermeter fails due to an invalid response status code
|
||||
TEST_F(LemDCBM400600ControllerTest, test_get_powermeter_fail_invalid_response_code) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, get("/v1/livemeasure"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{403, this->livemeasure_response}));
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.get_powermeter(), LemDCBM400600Controller::DCBMUnexpectedResponseException);
|
||||
}
|
||||
|
||||
/// \brief Test get_powermeter fails due to an invalid response status body
|
||||
TEST_F(LemDCBM400600ControllerTest, test_get_powermeter_fail_invalid_response_body) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, get("/v1/livemeasure"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, "invalid"}));
|
||||
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.get_powermeter(), LemDCBM400600Controller::DCBMUnexpectedResponseException);
|
||||
}
|
||||
|
||||
/// \brief Test get_powermeter fails due to an http client error
|
||||
TEST_F(LemDCBM400600ControllerTest, test_get_powermeter_fail_http_error) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, get("/v1/livemeasure"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Throw(HttpClientError("http client mock error")));
|
||||
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.get_powermeter(), HttpClientError);
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// Test start_transaction behavior
|
||||
|
||||
// \brief Test a successful start transaction
|
||||
TEST_F(LemDCBM400600ControllerTest, test_start_transaction) {
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, post("/v1/legal", this->expected_start_transaction_request_body))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{201, R"({"running": true})"}));
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
|
||||
// Act
|
||||
auto res = controller.start_transaction(this->transaction_request);
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(transaction_request_status_to_string(res.status), "OK");
|
||||
EXPECT_FALSE(res.error.has_value());
|
||||
EXPECT_THAT(res.transaction_min_stop_time.value(),
|
||||
testing::MatchesRegex("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z$"));
|
||||
EXPECT_THAT(res.transaction_max_stop_time.value(),
|
||||
testing::MatchesRegex("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z$"));
|
||||
|
||||
auto delta = Everest::Date::from_rfc3339(res.transaction_max_stop_time.value()) -
|
||||
Everest::Date::from_rfc3339(res.transaction_min_stop_time.value());
|
||||
EXPECT_EQ(
|
||||
int(delta.count() / 1E9 / 60),
|
||||
48 * 60 -
|
||||
3); // delta of max and min stopping time should be 48 hours - 2 minutes wait time and 1 minute safety time
|
||||
}
|
||||
|
||||
// \brief Test a failed start transaction with the DCBM returning an invalid response
|
||||
TEST_P(LemDCBM400600ControllerTestInvalidResponses, test_start_transaction_fail_invalid_response) {
|
||||
|
||||
// Setup
|
||||
// request fails due to an invalid response
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, post("/v1/legal", this->expected_start_transaction_request_body))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillRepeatedly(GetParam());
|
||||
EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, post("/v1/legal", this->expected_start_transaction_request_body))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillRepeatedly(GetParam());
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
|
||||
// Act
|
||||
auto res = controller.start_transaction(this->transaction_request);
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(transaction_request_status_to_string(res.status), "UNEXPECTED_ERROR");
|
||||
EXPECT_TRUE(res.error.has_value());
|
||||
EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to start transaction mock_transaction_id.*"));
|
||||
EXPECT_FALSE(res.transaction_min_stop_time.has_value());
|
||||
EXPECT_FALSE(res.transaction_max_stop_time.has_value());
|
||||
}
|
||||
|
||||
// Setup parametrized invalid responses
|
||||
static const std::string TEST_NAMES_START_TRANSACTION_INVALID_RESPONSES[2] = {"InvalidReturnCode",
|
||||
"InvalidResponseBody"};
|
||||
INSTANTIATE_TEST_SUITE_P(
|
||||
LemDCBM400600ControllerTestStartTransactionInvalidResponses, LemDCBM400600ControllerTestInvalidResponses,
|
||||
testing::Values(testing::Return(HttpResponse{403, ""}), testing::Return(HttpResponse{201, "invalid"})),
|
||||
[](const testing::TestParamInfo<LemDCBM400600ControllerTestInvalidResponses::ParamType>& info) {
|
||||
return TEST_NAMES_START_TRANSACTION_INVALID_RESPONSES[info.index];
|
||||
});
|
||||
|
||||
// \brief Test a failed start transaction with the http request failing
|
||||
TEST_F(LemDCBM400600ControllerTest, test_start_transaction_http_fail) {
|
||||
// Setup
|
||||
// request fails and throws an HttpClientError
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, post("/v1/legal", this->expected_start_transaction_request_body))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillRepeatedly(testing::Throw(HttpClientError{"mock error"}));
|
||||
EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, post("/v1/legal", this->expected_start_transaction_request_body))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillRepeatedly(testing::Throw(HttpClientError{"mock error"}));
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
|
||||
// Act
|
||||
auto res = controller.start_transaction(this->transaction_request);
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(transaction_request_status_to_string(res.status), "UNEXPECTED_ERROR");
|
||||
EXPECT_TRUE(res.error.has_value());
|
||||
EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to start transaction mock_transaction_id.*"));
|
||||
EXPECT_FALSE(res.transaction_min_stop_time.has_value());
|
||||
EXPECT_FALSE(res.transaction_max_stop_time.has_value());
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// Test stop_transaction behavior
|
||||
|
||||
// \brief Test to stop a transaction and receive OCMF report.
|
||||
TEST_F(LemDCBM400600ControllerTest, test_stop_transaction) {
|
||||
|
||||
// Setup
|
||||
EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(0);
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/legal?transactionId=mock_transaction_id", R"({"running": false})"))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_legal_response}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/ocmf?transactionId=mock_transaction_id"))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, "mock_ocmf_string"}));
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
|
||||
// Act
|
||||
auto res = controller.stop_transaction("mock_transaction_id");
|
||||
|
||||
// Verify
|
||||
ASSERT_EQ(transaction_request_status_to_string(res.status), "OK");
|
||||
ASSERT_TRUE(res.signed_meter_value.has_value());
|
||||
ASSERT_EQ(res.signed_meter_value.value().signed_meter_data, "mock_ocmf_string");
|
||||
}
|
||||
|
||||
// \brief Test a failed stop transaction with the DCBM returning an invalid response
|
||||
TEST_P(LemDCBM400600ControllerTestInvalidResponses, test_stop_transaction_fail_invalid_response) {
|
||||
// Setup
|
||||
// request fails repeatedly
|
||||
EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(0);
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/legal?transactionId=mock_transaction_id", R"({"running": false})"))
|
||||
.Times(2)
|
||||
.WillRepeatedly(GetParam());
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
|
||||
// Act
|
||||
auto res = controller.stop_transaction("mock_transaction_id");
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(transaction_request_status_to_string(res.status), "UNEXPECTED_ERROR");
|
||||
EXPECT_TRUE(res.error.has_value());
|
||||
EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to stop transaction mock_transaction_id:.*"));
|
||||
EXPECT_FALSE(res.signed_meter_value.has_value());
|
||||
}
|
||||
|
||||
// Setup parametrized invalid responses
|
||||
static const std::string TEST_NAMES_STOP_TRANSACTION_INVALID_RESPONSES[2] = {"InvalidReturnCode",
|
||||
"InvalidResponseBody"};
|
||||
INSTANTIATE_TEST_SUITE_P(
|
||||
LemDCBM400600ControllerTestStopTransactionInvalidResponses, LemDCBM400600ControllerTestInvalidResponses,
|
||||
testing::Values(testing::Return(HttpResponse{403, ""}), testing::Return(HttpResponse{200, "invalid"})),
|
||||
[](const testing::TestParamInfo<LemDCBM400600ControllerTestInvalidResponses::ParamType>& info) {
|
||||
return TEST_NAMES_STOP_TRANSACTION_INVALID_RESPONSES[info.index];
|
||||
});
|
||||
|
||||
// \brief Test a failed stop transaction with the http request failing
|
||||
TEST_F(LemDCBM400600ControllerTest, test_stop_transaction_http_fail) {
|
||||
// Setup
|
||||
// request fails repeatedly
|
||||
EXPECT_CALL(*this->time_sync_helper, sync(testing::_)).Times(0);
|
||||
EXPECT_CALL(*this->http_client, put("/v1/legal?transactionId=mock_transaction_id", R"({"running": false})"))
|
||||
.Times(2)
|
||||
.WillRepeatedly(testing::Throw(HttpClientError{"mock error"}));
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
|
||||
// Act
|
||||
auto res = controller.stop_transaction("mock_transaction_id");
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(transaction_request_status_to_string(res.status), "UNEXPECTED_ERROR");
|
||||
EXPECT_TRUE(res.error.has_value());
|
||||
EXPECT_THAT(res.error.value(), testing::MatchesRegex("Failed to stop transaction mock_transaction_id.*"));
|
||||
EXPECT_FALSE(res.signed_meter_value.has_value());
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// Test init behavior
|
||||
|
||||
// \brief Test the init method fetches the meter_id
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_meter_id) {
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, get("/v1/livemeasure"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{
|
||||
200,
|
||||
this->livemeasure_response,
|
||||
}));
|
||||
EXPECT_CALL(*this->http_client, get("/v1/status"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{
|
||||
200,
|
||||
R"({ "meterId": "mock_meter_id", "publicKeyOcmf": "KEY", "status": {"bits": {"transactionIsOnGoing": false}}, "version":{"applicationFirmwareVersion":"0.1.2.3"}, "some_other_field": "other_value" })",
|
||||
}));
|
||||
EXPECT_CALL(*this->http_client, get("/v1/legal"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"transactionId": "thetransactionid"})"}));
|
||||
EXPECT_CALL(*this->time_sync_helper, restart_unsafe_period()).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->time_sync_helper, sync_if_deadline_expired(testing::_)).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, get("/v1/livemeasure"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{
|
||||
200,
|
||||
this->livemeasure_response,
|
||||
}));
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config);
|
||||
|
||||
// Assert: no meter id set before init call
|
||||
EXPECT_EQ(controller.get_powermeter().meter_id, "");
|
||||
|
||||
// Act: initialize
|
||||
struct ntp_server_spec ntp_spec;
|
||||
controller.init();
|
||||
|
||||
// verify by calling the powermeter interface that should provide the mocked metric id
|
||||
EXPECT_EQ(controller.get_powermeter().meter_id, "mock_meter_id");
|
||||
}
|
||||
|
||||
// \brief Test the init method retries to fetch the meter id in case of a HttpClientError
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_meter_id_retry_success) {
|
||||
|
||||
// Setup
|
||||
int number_of_retries = 3;
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/v1/status"))
|
||||
.Times(number_of_retries - 1)
|
||||
.InSequence(seq)
|
||||
.WillRepeatedly(testing::Throw(HttpClientError{"mock error"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/status"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{
|
||||
200,
|
||||
R"({ "meterId": "mock_meter_id", "publicKeyOcmf": "KEY", "status": {"bits": {"transactionIsOnGoing": false}}, "version":{"applicationFirmwareVersion":"0.1.2.3"}, "some_other_field": "other_value" })",
|
||||
}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/legal"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"transactionId": "thetransactionid"})"}));
|
||||
|
||||
EXPECT_CALL(*this->time_sync_helper, restart_unsafe_period()).Times(1).InSequence(seq);
|
||||
EXPECT_CALL(*this->http_client, get("/v1/livemeasure"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillRepeatedly(testing::Return(HttpResponse{
|
||||
200,
|
||||
this->livemeasure_response,
|
||||
}));
|
||||
const LemDCBM400600Controller::Conf controller_config{number_of_retries, 1, 1, 0, 0, 0, {}, {}, 0, {}, {}, -1};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
controller_config);
|
||||
|
||||
// Act
|
||||
struct ntp_server_spec ntp_spec;
|
||||
controller.init();
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(controller.get_powermeter().meter_id, "mock_meter_id");
|
||||
}
|
||||
|
||||
// \brief Test at init the HttpClientError is re-raised after the provided number of attempts all failed
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_meter_id_retry_fail_eventually) {
|
||||
// Setup
|
||||
int number_of_retries = 3;
|
||||
EXPECT_CALL(*this->http_client, get("/v1/status"))
|
||||
.Times(1 + number_of_retries)
|
||||
.WillRepeatedly(testing::Throw(HttpClientError{"mock error"}));
|
||||
EXPECT_CALL(*this->time_sync_helper, restart_unsafe_period()).Times(0);
|
||||
|
||||
const LemDCBM400600Controller::Conf controller_config{number_of_retries, 1, 1, 0, 0, 0, {}, {}, 0, {}, {}, -1};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
controller_config);
|
||||
|
||||
// Act & Verify
|
||||
struct ntp_server_spec ntp_spec;
|
||||
EXPECT_THROW(controller.init(), HttpClientError);
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// Test set_identification_type behavior (IT field)
|
||||
|
||||
// Helper to set up a mock that successfully completes init() (fetch_meter_id + restart + set_command_timeout)
|
||||
#define SETUP_SUCCESSFUL_INIT(seq) \
|
||||
EXPECT_CALL(*this->http_client, get("/v1/status")) \
|
||||
.Times(1) \
|
||||
.InSequence(seq) \
|
||||
.WillOnce(testing::Return(HttpResponse{ \
|
||||
200, \
|
||||
R"({ "meterId": "mock_meter_id", "publicKeyOcmf": "KEY", "status": {"bits": {"transactionIsOnGoing": false}}, "version":{"applicationFirmwareVersion":"0.1.2.3"} })", \
|
||||
})); \
|
||||
EXPECT_CALL(*this->http_client, get("/v1/legal")) \
|
||||
.Times(1) \
|
||||
.InSequence(seq) \
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"transactionId": "thetransactionid"})"})); \
|
||||
EXPECT_CALL(*this->time_sync_helper, restart_unsafe_period()).Times(1).InSequence(seq)
|
||||
|
||||
/// \brief Test init() with IT = -1 does not call set_identification_type
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_skips_set_it_when_minus_one) {
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
SETUP_SUCCESSFUL_INIT(seq);
|
||||
|
||||
// No PUT /v1/settings expected
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::_)).Times(0);
|
||||
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper),
|
||||
this->controller_config); // IT = -1
|
||||
|
||||
// Act
|
||||
controller.init();
|
||||
}
|
||||
|
||||
/// \brief Test init() with IT >= 0 calls set_identification_type successfully
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_sets_it_when_valid) {
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":3}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", R"({"ocmfId":{"IT":5}})"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"result": 1})"}));
|
||||
|
||||
SETUP_SUCCESSFUL_INIT(seq);
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 5};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act - should not throw
|
||||
controller.init();
|
||||
}
|
||||
|
||||
/// \brief Test set_identification_type throws on non-200 response code
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_set_it_fails_on_bad_status_code) {
|
||||
// Setup - IT is attempted first; fetch_meter_id is never reached when IT fails
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":0}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", R"({"ocmfId":{"IT":3}})"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{500, "Internal Server Error"}));
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 3};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.init(), LemDCBM400600Controller::DCBMUnexpectedResponseException);
|
||||
}
|
||||
|
||||
/// \brief Test set_identification_type throws when device rejects the setting (result != 1)
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_set_it_fails_on_rejected) {
|
||||
// Setup - IT is attempted first; fetch_meter_id is never reached when IT fails
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":0}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", R"({"ocmfId":{"IT":3}})"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"result": 0})"}));
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 3};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.init(), LemDCBM400600Controller::DCBMUnexpectedResponseException);
|
||||
}
|
||||
|
||||
/// \brief Test set_identification_type throws on malformed JSON response body
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_set_it_fails_on_malformed_json) {
|
||||
// Setup - IT is attempted first; fetch_meter_id is never reached when IT fails
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":0}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", R"({"ocmfId":{"IT":3}})"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, "not json"}));
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 3};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.init(), LemDCBM400600Controller::DCBMUnexpectedResponseException);
|
||||
}
|
||||
|
||||
/// \brief Test set_identification_type throws when JSON body is missing "result" key
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_set_it_fails_on_missing_result_key) {
|
||||
// Setup - IT is attempted first; fetch_meter_id is never reached when IT fails
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":0}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", R"({"ocmfId":{"IT":3}})"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"status": "ok"})"}));
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 3};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.init(), LemDCBM400600Controller::DCBMUnexpectedResponseException);
|
||||
}
|
||||
|
||||
/// \brief Test init() with IT >= 0 skips set_identification_type when device already has the configured value
|
||||
TEST_F(LemDCBM400600ControllerTest, test_init_skips_set_it_when_already_configured) {
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
|
||||
EXPECT_CALL(*this->http_client, get("/v1/settings"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, R"({"ocmfId":{"IT":5}})"}));
|
||||
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::_)).Times(0);
|
||||
|
||||
SETUP_SUCCESSFUL_INIT(seq);
|
||||
|
||||
const LemDCBM400600Controller::Conf config_with_it{0, 0, 1, 0, 0, 0, {}, {}, 0, {}, {}, 5};
|
||||
LemDCBM400600Controller controller(std::move(this->http_client), std::move(this->time_sync_helper), config_with_it);
|
||||
|
||||
// Act - should not throw
|
||||
controller.init();
|
||||
}
|
||||
|
||||
} // namespace module::main
|
||||
@@ -0,0 +1,194 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import timedelta, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lem_dcbm_test_utils.dcbm import DCBMInterface
|
||||
from lem_dcbm_test_utils.everest import LemDCBMStandaloneEverestInstance, LecmDDCBMModuleConfig, \
|
||||
StartTransactionSuccessResponse, StopTransactionSuccessResponse
|
||||
from lem_dcbm_test_utils.types import Powermeter
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def dcbm_host(request) -> str:
|
||||
host = request.config.getoption("--lem-dcbm-host")
|
||||
assert host
|
||||
return host
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def dcbm_port(request) -> int:
|
||||
port = int(request.config.getoption("--lem-dcbm-port"))
|
||||
assert port
|
||||
return port
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def everest_test_instance(request, dcbm_host, dcbm_port, dcbm) -> LemDCBMStandaloneEverestInstance:
|
||||
"""Fixture that can be used to start and stop EVerest"""
|
||||
everest_prefix = Path(request.config.getoption("--everest-prefix"))
|
||||
try:
|
||||
dcbm.reset_device()
|
||||
with LemDCBMStandaloneEverestInstance(everest_prefix=everest_prefix,
|
||||
config=LecmDDCBMModuleConfig(ip_address=dcbm_host,
|
||||
port=dcbm_port)) as everest:
|
||||
yield everest
|
||||
finally:
|
||||
dcbm.reset_device()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def everest_test_instance_ntp_configured(request, dcbm_host, dcbm_port, dcbm) -> LemDCBMStandaloneEverestInstance:
|
||||
"""Fixture that can be used to start and stop EVerest"""
|
||||
everest_prefix = Path(request.config.getoption("--everest-prefix"))
|
||||
try:
|
||||
dcbm.reset_device()
|
||||
with LemDCBMStandaloneEverestInstance(everest_prefix=everest_prefix,
|
||||
config=LecmDDCBMModuleConfig(ip_address=dcbm_host,
|
||||
port=dcbm_port,
|
||||
ntp_server_1_ip_addr="test_ntp_1",
|
||||
ntp_server_1_port=124,
|
||||
ntp_server_2_ip_addr="test_ntp_2",
|
||||
ntp_server_2_port=125
|
||||
)) as everest:
|
||||
yield everest
|
||||
finally:
|
||||
dcbm.reset_device()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def everest_test_instance_tls(request, dcbm_host, dcbm_port, dcbm) -> LemDCBMStandaloneEverestInstance:
|
||||
"""Fixture that can be used to start and stop EVerest"""
|
||||
everest_prefix = Path(request.config.getoption("--everest-prefix"))
|
||||
try:
|
||||
dcbm.reset_device()
|
||||
certificate = dcbm.get_certificate()
|
||||
certificate = certificate.replace("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n").replace(
|
||||
"-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----")
|
||||
dcbm.activate_tls_via_http()
|
||||
with LemDCBMStandaloneEverestInstance(everest_prefix=everest_prefix,
|
||||
config=LecmDDCBMModuleConfig(ip_address=dcbm_host, port=dcbm_port,
|
||||
meter_tls_certificate=certificate)) as everest:
|
||||
yield everest
|
||||
finally:
|
||||
dcbm.reset_device()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def dcbm(dcbm_host, dcbm_port):
|
||||
dcbm = DCBMInterface(host=dcbm_host, port=dcbm_port)
|
||||
return dcbm
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lem_dcbm_e2e_powermeter_does_regular_publish(everest_test_instance, dcbm):
|
||||
for i in range(2):
|
||||
logging.info(f"waiting for {i + 1}th powermeter publications")
|
||||
await everest_test_instance.probe_module.poll_next_powermeter(1.25)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lem_dcbm_e2e_powermeter_meterid_correct(everest_test_instance):
|
||||
power_meter: Powermeter = await everest_test_instance.probe_module.poll_next_powermeter(1.25)
|
||||
assert re.match(
|
||||
r"^\d+$", power_meter.meter_id), f"got unexpected meter_id {power_meter.meter_id}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lem_dcbm_e2e_start_stop_transaction(everest_test_instance, dcbm):
|
||||
assert await dcbm.wait_for_status(17), "device has invalid status before transaction start"
|
||||
|
||||
start_result = everest_test_instance.probe_module.call_powermeter_command('start_transaction',
|
||||
{"value": {
|
||||
"evse_id": "mock_evse_id",
|
||||
"transaction_id": "e2e_test_transaction",
|
||||
"identification_status": "ASSIGNED",
|
||||
"identification_flags": []
|
||||
}})
|
||||
|
||||
parsed_start_result = StartTransactionSuccessResponse(**start_result)
|
||||
assert 48 * 60 - 3.1 < ((
|
||||
parsed_start_result.transaction_max_stop_time - parsed_start_result.transaction_min_stop_time).total_seconds() / 60) <= 48 * 60 - 2.9
|
||||
|
||||
logging.info("started transaction 'e2e_test_transaction'")
|
||||
|
||||
assert await dcbm.wait_for_status(21), "device has invalid status after transaction start"
|
||||
|
||||
stop_result = everest_test_instance.probe_module.call_powermeter_command('stop_transaction',
|
||||
{"transaction_id": "e2e_test_transaction"}
|
||||
)
|
||||
|
||||
StopTransactionSuccessResponse(**stop_result)
|
||||
|
||||
logging.info("stopped transaction 'e2e_test_transaction'")
|
||||
|
||||
assert await dcbm.wait_for_status(17), "device has invalid status after transaction stop"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lem_dcbm_e2e_time_sync(everest_test_instance, dcbm):
|
||||
""" Check time gets synced per default
|
||||
|
||||
:param everest_test_instance:
|
||||
:param dcbm:
|
||||
:return:
|
||||
"""
|
||||
|
||||
# start transaction to enforce early sync; tidied up by fixture
|
||||
assert everest_test_instance.probe_module.call_powermeter_command('start_transaction',
|
||||
{"value": {
|
||||
"evse_id": "mock_evse_id",
|
||||
"transaction_id": "e2e_test_transaction",
|
||||
"identification_status": "ASSIGNED",
|
||||
"identification_flags": [],
|
||||
"identification_type": "ISO14443"
|
||||
}})["status"] == "OK"
|
||||
|
||||
dcbm.set_time(datetime.now() - timedelta(days=365))
|
||||
|
||||
async def check_time():
|
||||
while ((dcbm.get_status().time.astimezone(timezone.utc) - datetime.now(timezone.utc)).total_seconds() > 60):
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
await asyncio.wait_for(check_time(), 2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lem_dcbm_e2e_ntp_setup(everest_test_instance_ntp_configured, dcbm):
|
||||
""" Test ntp is setup correctly and activated if configured. """
|
||||
|
||||
# start transaction to enforce early sync; tidied up by fixture
|
||||
assert everest_test_instance_ntp_configured.probe_module.call_powermeter_command('start_transaction',
|
||||
{"value": {
|
||||
"evse_id": "mock_evse_id",
|
||||
"transaction_id": "e2e_test_transaction",
|
||||
"identification_status": "ASSIGNED",
|
||||
"identification_flags": [],
|
||||
"identification_type": "ISO14443"
|
||||
}})["status"] == "OK"
|
||||
|
||||
async def check():
|
||||
while not (ntp_settings := dcbm.get_ntp_settings()).ntpActivated:
|
||||
await asyncio.sleep(0.25)
|
||||
return ntp_settings
|
||||
|
||||
ntp_settings = await asyncio.wait_for(check(), timeout=2)
|
||||
assert ntp_settings.ntpActivated is True
|
||||
assert ntp_settings.servers == [{
|
||||
"ipAddress": "test_ntp_1",
|
||||
"port": 124
|
||||
},
|
||||
{
|
||||
"ipAddress": "test_ntp_2",
|
||||
"port": 125
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lem_dcbm_2e_get_powermeter_tls(everest_test_instance_tls):
|
||||
power_meter: Powermeter = await everest_test_instance_tls.probe_module.poll_next_powermeter(1.25)
|
||||
assert re.match(
|
||||
r"^\d+$", power_meter.meter_id), f"got unexpected meter_id {power_meter.meter_id}"
|
||||
@@ -0,0 +1,46 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lem_dcbm_test_utils.everest import LemDCBMStandaloneEverestInstance, LecmDDCBMModuleConfig, \
|
||||
StartTransactionSuccessResponse, StopTransactionSuccessResponse
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def everest_test_instance(request, lem_dcbm_mock) -> LemDCBMStandaloneEverestInstance:
|
||||
"""Fixture that can be used to start and stop EVerest"""
|
||||
everest_prefix = Path(request.config.getoption("--everest-prefix"))
|
||||
with LemDCBMStandaloneEverestInstance(everest_prefix=everest_prefix,
|
||||
config=LecmDDCBMModuleConfig(ip_address="localhost",
|
||||
port=8000)) as everest:
|
||||
yield everest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_powermeter(everest_test_instance):
|
||||
for i in range(2):
|
||||
logging.info(f"waiting for {i + 1}th powermeter publications")
|
||||
await everest_test_instance.probe_module.poll_next_powermeter(1.25)
|
||||
|
||||
|
||||
def test_start_transaction(everest_test_instance):
|
||||
res = everest_test_instance.probe_module.call_powermeter_command('start_transaction',
|
||||
{"value": {
|
||||
"evse_id": "mock_evse_id",
|
||||
"transaction_id": "e2e_test_transaction",
|
||||
"identification_status": "ASSIGNED",
|
||||
"identification_flags": [],
|
||||
"identification_type": "ISO14443"
|
||||
}})
|
||||
|
||||
parsed_start_result = StartTransactionSuccessResponse(**res)
|
||||
assert 48 * 60 - 3.1 < ((
|
||||
parsed_start_result.transaction_max_stop_time - parsed_start_result.transaction_min_stop_time).total_seconds() / 60) <= 48 * 60 - 2.9
|
||||
|
||||
|
||||
def test_stop_transaction(everest_test_instance):
|
||||
res = everest_test_instance.probe_module.call_powermeter_command('stop_transaction',
|
||||
{"transaction_id": "mock_transaction_id"}
|
||||
)
|
||||
StopTransactionSuccessResponse(**res)
|
||||
@@ -0,0 +1,292 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
// Unit tests for the LemDCBMTimeSyncHelper
|
||||
|
||||
#include "http_client_interface.hpp"
|
||||
#include "lem_dcbm_400600_controller.hpp"
|
||||
#include "lem_dcbm_time_sync_helper.hpp"
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace module::main {
|
||||
|
||||
class HTTPClientMock : public HttpClientInterface {
|
||||
public:
|
||||
MOCK_METHOD(HttpResponse, get, (const std::string& path), (override, const));
|
||||
MOCK_METHOD(HttpResponse, post, (const std::string& path, const std::string& body), (override, const));
|
||||
MOCK_METHOD(HttpResponse, put, (const std::string& path, const std::string& body), (override, const));
|
||||
};
|
||||
|
||||
// Fixture class providing
|
||||
// - a http client mock
|
||||
// - a mock of the time sync helper
|
||||
// - default responses & request objects
|
||||
class LemDCBMTimeSyncHelperTest : public ::testing::Test {
|
||||
|
||||
protected:
|
||||
std::unique_ptr<HTTPClientMock> http_client;
|
||||
|
||||
const std::string put_settings_response_success{R"({
|
||||
"meterId": "mock_meter_id",
|
||||
"result": 1
|
||||
})"};
|
||||
|
||||
const std::string put_settings_response_fail{R"({
|
||||
"meterId": "mock_meter_id",
|
||||
"result": 0
|
||||
})"};
|
||||
|
||||
const std::string expected_system_sync_request_regex{
|
||||
R"(\{"time":\{"utc":"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z"\}\})"};
|
||||
|
||||
const std::string expected_tz_sync_request_regex{R"(\{"time": \{"tz":""\}\})"};
|
||||
|
||||
const std::string expected_dst_sync_request_regex{R"(\{"time": \{"dst":\}\})"};
|
||||
|
||||
const std::string expected_ntp_sync_request{
|
||||
R"({"ntp":{"servers":[{"ipAddress":"123.123.123.123","port":123},{"ipAddress":"213.213.213.213","port":213}],"syncPeriod":120,"ntpActivated":true}})"};
|
||||
|
||||
const struct ntp_server_spec spec_ntp_disabled {};
|
||||
|
||||
const struct ntp_server_spec spec_ntp_enabled {
|
||||
"123.123.123.123", 123, "213.213.213.213", 213
|
||||
};
|
||||
|
||||
void SetUp() override {
|
||||
this->http_client = std::make_unique<HTTPClientMock>();
|
||||
}
|
||||
};
|
||||
|
||||
// Extended fixture for parametrizing tests for invalid response checks
|
||||
class LemDCBMTimeSyncHelperTestInvalidResponses
|
||||
: public LemDCBMTimeSyncHelperTest,
|
||||
public ::testing::WithParamInterface<testing::internal::ReturnAction<HttpResponse>> {};
|
||||
|
||||
//****************************************************************
|
||||
|
||||
/// \brief sync() sends correct HTTP request when in system time mode
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_success_system_time) {
|
||||
std::string input_to_put;
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_dst_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client,
|
||||
put("/v1/settings", testing::ContainsRegex(this->expected_system_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_disabled);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
helper.sync(*this->http_client);
|
||||
}
|
||||
|
||||
/// \brief sync() sends correct HTTP request when in NTP mode
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_success_ntp) {
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_dst_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_enabled);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
helper.sync(*this->http_client);
|
||||
}
|
||||
|
||||
/// \brief sync() throws an exception if it gets a status code other than 200, system time mode
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_if_status_code_not_200_sys_time) {
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client,
|
||||
put("/v1/settings", testing::ContainsRegex(this->expected_system_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{400, ""}));
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_disabled);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
EXPECT_THROW(helper.sync(*this->http_client), LemDCBM400600Controller::UnexpectedDCBMResponseCode);
|
||||
}
|
||||
|
||||
/// \brief sync() throws an exception if it gets a status code other than 200, NTP mode
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_if_status_code_not_200_ntp) {
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{400, ""}));
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_enabled);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
EXPECT_THROW(helper.sync(*this->http_client), LemDCBM400600Controller::UnexpectedDCBMResponseCode);
|
||||
}
|
||||
|
||||
/// \brief sync() throws exception if it gets a response of 200, but a failed write (result=0), system time mode
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_if_200_but_result_is_0_sys_time) {
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client,
|
||||
put("/v1/settings", testing::ContainsRegex(this->expected_system_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_fail}));
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_disabled);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
EXPECT_THROW(helper.sync(*this->http_client), LemDCBM400600Controller::UnexpectedDCBMResponseBody);
|
||||
}
|
||||
|
||||
/// \brief sync() throws exception if it gets a response of 200, but a failed write (result=0), NTP mode
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_if_200_but_result_is_0_ntp) {
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_fail}));
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_enabled);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
EXPECT_THROW(helper.sync(*this->http_client), LemDCBM400600Controller::UnexpectedDCBMResponseBody);
|
||||
}
|
||||
|
||||
/// \brief sync_if_deadline_expired() called twice will not send anything the second time if the first call succeeds
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_if_deadline_expired_twice_when_first_succeeds) {
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_dst_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client,
|
||||
put("/v1/settings", testing::ContainsRegex(this->expected_system_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
|
||||
// override the timing constants so that we don't need to wait for the time barriers to pass
|
||||
// We do set deadline_increment_after_sync though, as we want to ensure it has not passed
|
||||
struct timing_config timing_constants {
|
||||
std::chrono::seconds(0), std::chrono::seconds(0), std::chrono::seconds(10000000),
|
||||
};
|
||||
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_disabled, timing_constants);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
helper.sync_if_deadline_expired(*this->http_client);
|
||||
helper.sync_if_deadline_expired(*this->http_client);
|
||||
}
|
||||
|
||||
/// \brief sync_if_deadline_expired() called twice will not send anything the second time, even if the first call fails
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_if_deadline_expired_twice_when_first_fails) {
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client,
|
||||
put("/v1/settings", testing::ContainsRegex(this->expected_system_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_fail}));
|
||||
|
||||
// override the timing constants so that we don't need to wait for the time barriers to pass
|
||||
// We do set min_time_between_sync_retries though, as we want to ensure it has not passed
|
||||
struct timing_config timing_constants {
|
||||
std::chrono::seconds(0), std::chrono::seconds(1000000), std::chrono::seconds(0),
|
||||
};
|
||||
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_disabled, timing_constants);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
helper.sync_if_deadline_expired(*this->http_client);
|
||||
helper.sync_if_deadline_expired(*this->http_client);
|
||||
}
|
||||
|
||||
/// \brief sync() in NTP mode will not send the sync twice if the first sync succeeded
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_twice_if_first_succeeds) {
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_dst_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
|
||||
// override the timing constants so that we don't need to wait for the time barriers to pass
|
||||
struct timing_config timing_constants {
|
||||
std::chrono::seconds(0), std::chrono::seconds(0), std::chrono::seconds(0),
|
||||
};
|
||||
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_enabled, timing_constants);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
helper.sync(*this->http_client);
|
||||
helper.sync(*this->http_client);
|
||||
}
|
||||
|
||||
/// \brief sync() in NTP mode will send the sync twice, even if the first sync succeeded, if it's not safe to save
|
||||
/// settings yet
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_twice_if_first_succeeds_before_safe) {
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_dst_sync_request_regex)))
|
||||
.Times(2)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}))
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(2)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}))
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request))
|
||||
.Times(2)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}))
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
|
||||
// override the timing constants so that we don't need to wait for the time barriers to pass
|
||||
// we do set min_time_before_setting_write_is_safe as we want to ensure it hasn't passed yet though
|
||||
struct timing_config timing_constants {
|
||||
std::chrono::seconds(1000000), std::chrono::seconds(0), std::chrono::seconds(0),
|
||||
};
|
||||
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_enabled, timing_constants);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
helper.sync(*this->http_client);
|
||||
helper.sync(*this->http_client);
|
||||
}
|
||||
|
||||
/// \brief sync() in NTP mode will send the sync twice if the first sync failed
|
||||
TEST_F(LemDCBMTimeSyncHelperTest, test_sync_exception_twice_if_first_fails) {
|
||||
// Setup
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_dst_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", testing::ContainsRegex(this->expected_tz_sync_request_regex)))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
EXPECT_CALL(*this->http_client, put("/v1/settings", expected_ntp_sync_request))
|
||||
.Times(2)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_fail}))
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->put_settings_response_success}));
|
||||
LemDCBMTimeSyncHelper helper(spec_ntp_enabled);
|
||||
helper.restart_unsafe_period();
|
||||
|
||||
// Act
|
||||
EXPECT_THROW(helper.sync(*this->http_client), LemDCBM400600Controller::UnexpectedDCBMResponseBody);
|
||||
helper.sync(*this->http_client);
|
||||
}
|
||||
|
||||
} // namespace module::main
|
||||
@@ -0,0 +1,213 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <thread>
|
||||
|
||||
#include "temperature_monitor.hpp"
|
||||
|
||||
using module::main::TemperatureMonitor;
|
||||
|
||||
namespace {
|
||||
|
||||
TemperatureMonitor make_monitor(double warning_level_C, double error_level_C, double hysteresis_K,
|
||||
int min_time_as_valid_ms) {
|
||||
return TemperatureMonitor(TemperatureMonitor::Config{warning_level_C, error_level_C, hysteresis_K,
|
||||
std::chrono::milliseconds(min_time_as_valid_ms)});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(TemperatureMonitorTest, NoEventsBelowThresholds) {
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, /*min_time_ms*/ 0);
|
||||
|
||||
auto events = monitor.update(40.0, 41.0);
|
||||
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, WarningRaisedImmediatelyWhenAboveWarningAndZeroMinTime) {
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, /*min_time_ms*/ 0);
|
||||
|
||||
auto events = monitor.update(51.0, 49.0); // max = 51 > 50
|
||||
|
||||
EXPECT_TRUE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, ErrorRaisedImmediatelyWhenAboveErrorAndZeroMinTime) {
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, /*min_time_ms*/ 0);
|
||||
|
||||
auto events = monitor.update(61.0, 59.0); // max = 61 > 60 (also > 50 warning threshold)
|
||||
|
||||
// When temperature exceeds error threshold, it also exceeds warning threshold,
|
||||
// so both warning and error should be raised
|
||||
EXPECT_TRUE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
EXPECT_TRUE(events.error_raised);
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, WarningClearsWithHysteresis) {
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, /*min_time_ms*/ 0);
|
||||
|
||||
// Raise warning
|
||||
auto events = monitor.update(52.0, 51.0);
|
||||
EXPECT_TRUE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
|
||||
// Still above warning - hysteresis threshold, no clear
|
||||
events = monitor.update(49.0, 48.5); // max = 49, threshold - hyst = 47
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
|
||||
// Drop below warning - hysteresis threshold, should clear
|
||||
events = monitor.update(46.0, 45.0); // max = 46 < 47
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_TRUE(events.warning_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, ErrorClearsWithHysteresis) {
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 5.0, /*min_time_ms*/ 0);
|
||||
|
||||
// Raise error
|
||||
auto events = monitor.update(61.0, 62.0);
|
||||
EXPECT_TRUE(events.error_raised);
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
|
||||
// Still above error - hysteresis threshold, no clear
|
||||
events = monitor.update(56.0, 55.0); // error threshold - hyst = 55
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
|
||||
// Drop below error - hysteresis threshold, should clear
|
||||
events = monitor.update(54.0, 53.0); // max = 54 < 55
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_TRUE(events.error_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, WarningNotRaisedBeforeMinTimeElapses) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// First update: temperature exceeds threshold, but min_time hasn't elapsed
|
||||
auto events = monitor.update(51.0, 50.5); // max = 51 > 50
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
|
||||
// Wait less than min_time - still no warning
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 2));
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, WarningRaisedAfterMinTimeElapses) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// First update: temperature exceeds threshold, starts timer
|
||||
auto events = monitor.update(51.0, 50.5); // max = 51 > 50
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
// Wait for min_time to elapse
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms + 10)); // Add small buffer for test timing
|
||||
|
||||
// Next update after min_time elapsed should raise warning
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_TRUE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, TimerResetsWhenTemperatureDropsBelowThreshold) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// Start timing: temperature exceeds threshold
|
||||
auto events = monitor.update(51.0, 50.5); // max = 51 > 50
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
// Wait part of the min_time
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 2));
|
||||
|
||||
// Temperature drops below threshold - timer should reset
|
||||
events = monitor.update(49.0, 48.0); // max = 49 < 50
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
|
||||
// Wait again for min_time - but timer was reset, so we need to start over
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 2));
|
||||
events = monitor.update(49.0, 48.0);
|
||||
EXPECT_FALSE(events.warning_raised); // Still below threshold
|
||||
|
||||
// Now exceed threshold again and wait full min_time
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms + 10));
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_TRUE(events.warning_raised); // Now should be raised
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, ErrorNotRaisedBeforeMinTimeElapses) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// Temperature exceeds error threshold
|
||||
auto events = monitor.update(61.0, 60.5); // max = 61 > 60
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.warning_raised); // Also above warning, but min_time applies
|
||||
|
||||
// Wait less than min_time
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 2));
|
||||
events = monitor.update(61.0, 60.5);
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, ErrorRaisedAfterMinTimeElapses) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// Start timing: temperature exceeds error threshold
|
||||
auto events = monitor.update(61.0, 60.5); // max = 61 > 60
|
||||
EXPECT_FALSE(events.error_raised);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
// Wait for min_time to elapse
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms + 10));
|
||||
|
||||
// Next update after min_time elapsed should raise both warning and error
|
||||
events = monitor.update(61.0, 60.5);
|
||||
EXPECT_TRUE(events.error_raised);
|
||||
EXPECT_TRUE(events.warning_raised); // Also above warning threshold
|
||||
EXPECT_FALSE(events.error_cleared);
|
||||
EXPECT_FALSE(events.warning_cleared);
|
||||
}
|
||||
|
||||
TEST(TemperatureMonitorTest, MultipleUpdatesAccumulateTimeCorrectly) {
|
||||
constexpr int min_time_ms = 100;
|
||||
auto monitor = make_monitor(/*warning*/ 50.0, /*error*/ 60.0, /*hyst*/ 3.0, min_time_ms);
|
||||
|
||||
// First update starts timer
|
||||
auto events = monitor.update(51.0, 50.5);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
// Multiple updates while above threshold should accumulate time
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 3));
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 3));
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_FALSE(events.warning_raised);
|
||||
|
||||
// Final update after total time >= min_time should raise warning
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(min_time_ms / 3 + 10));
|
||||
events = monitor.update(51.0, 50.5);
|
||||
EXPECT_TRUE(events.warning_raised);
|
||||
}
|
||||
Reference in New Issue
Block a user