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