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:
5
tools/EVerest-main/applications/utils/everest-testing/.gitignore
vendored
Normal file
5
tools/EVerest-main/applications/utils/everest-testing/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
build
|
||||
__pycache__
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
results.xml
|
||||
@@ -0,0 +1,26 @@
|
||||
load("//applications/utils:requirements.bzl", "requirement")
|
||||
load("@rules_python//python:defs.bzl", "py_library")
|
||||
load("//third-party/bazel/toolchains:defs.bzl", "CROSS_PYTHON_INCOMPATIBLE")
|
||||
|
||||
exports_files(
|
||||
["BUILD.bazel"],
|
||||
)
|
||||
|
||||
py_library(
|
||||
name = "everest-testing",
|
||||
srcs = glob(["src/**/*.py"]),
|
||||
imports = ["src"],
|
||||
visibility = ["//visibility:public"],
|
||||
target_compatible_with = CROSS_PYTHON_INCOMPATIBLE,
|
||||
deps = [
|
||||
requirement("pytest"),
|
||||
requirement("pytest-asyncio"),
|
||||
requirement("python-dateutil"),
|
||||
requirement("paho-mqtt"),
|
||||
requirement("pyftpdlib"),
|
||||
requirement("ocpp"),
|
||||
requirement("websockets"),
|
||||
requirement("pyOpenSSL"),
|
||||
requirement("pyyaml"),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
ev_create_pip_install_targets(
|
||||
PACKAGE_NAME
|
||||
"everest-testing"
|
||||
)
|
||||
|
||||
ev_create_python_wheel_targets(
|
||||
PACKAGE_NAME
|
||||
"everest-testing"
|
||||
)
|
||||
148
tools/EVerest-main/applications/utils/everest-testing/README.md
Normal file
148
tools/EVerest-main/applications/utils/everest-testing/README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Everest Testing
|
||||
|
||||
This python package provides utility for testing EVerest with pytest.
|
||||
|
||||
The utilities are seperated into
|
||||
|
||||
- core_utils: Providing classes and fixtures to get a running and properly configured EVerest instance
|
||||
- ocpp_utils (under development): Providing class and fixtures to test against an OCPP1.6J or OCPP2.0.1J central system
|
||||
|
||||
## Core Utils
|
||||
|
||||
### everest_core
|
||||
The EverestCore class wraps the running EVerest instance and takes care of providing a proper set up environment (including
|
||||
the generation of temporary directories and adjusting the configuration accordingly. Note that in order to do so, EverestCore generates
|
||||
a temporary configuration file.)
|
||||
|
||||
### test_controller
|
||||
|
||||
Controller that can be used to start/stop the Everest instance and send events to control/simulate the stack's behavior.
|
||||
|
||||
### Fixtures
|
||||
|
||||
The core_utils basically provide two fixtures that you can require in your test cases:
|
||||
|
||||
- **everest_core** The main fixture `everest_core` can be used to start and stop the EVerest application.
|
||||
- **test_controller**: Fixture that references the test_controller that can be used for control events for the test cases. This includes control over simulations that trigger events like an EV plug in, EV plug out, swipe RFID and more.
|
||||
|
||||
#### Configuration Fixtures:
|
||||
|
||||
- **core_config** Core configuration, which is the EVerest path and the configuration path (utilizes the `everest_core_config` marker.)
|
||||
- **probe_module_config** Used to provide the probe module configuration. In particular can be overriden if the probe module should require module connections.
|
||||
- **ocpp_config** Used to provide the configuration, i.e. the JSON device model, to set up the OCPP (1.6 or 2.0.1) module.
|
||||
- **evse_security_config** Used to provide the configuration to set up the EvseSecurity module.
|
||||
- **persistent_storage_config** Used to provide the configuration to set up the PersistentStorage module.
|
||||
- **everest_config_strategies**: Provides a list of additional `EverestConfigAdjustmentStrategy` instances that are called to manipulate the resulting Everest configuration.
|
||||
|
||||
### pytest markers
|
||||
|
||||
Some OCPP fixtures will parse pytest markers of test cases. The following markers can be used:
|
||||
- **everest_core_config**: Can be used to specify the everest configuration file to be used in this test case
|
||||
- **standalone_module**: Define one or several modules as standalone (multiple modules via `@pytest.mark.standalone_module("mod1","mod2")`)
|
||||
- **probe_module**: If set, the ProbeModule will be injected into the config (used by the `probe_module_config` fixture). This marker accepts optional keyword arguments `connections` and `module_id` to configure the probe module.
|
||||
- **source_certs_dir**: If set and the default `evse_security_config` fixture is used, this will cause the `EvseSecurity` module configuration to use a temporary certificates folder into which the source certificate folder trees are copied.
|
||||
- **use_temporary_persistent_store**: If set and the default `persistent_storage_config` fixture is used, this will cause the `PersistentStore` module configuration to use a temporary database.
|
||||
- **everest_config_adaptions**: Can be given instances of `EverestConfigAdjustmentStrategy` as positional arguments which will be applied to the resulting Everest configuration.
|
||||
|
||||
## OCPP utils
|
||||
|
||||
The ocpp utils provide fixture which you can require in your test cases in order to start a central system and initiate operations.
|
||||
These utilities are still under development.
|
||||
|
||||
- **central_system_v16**: Fixture that starts up an OCPP1.6 central system. Can be started as TLS or plain websocket depending on the request parameter.
|
||||
- **central_system_v201**: Fixture that starts up an OCPP2.0.1 central system. Can be started as TLS or plain websocket depending on the request parameter.
|
||||
- **charge_point_v16**: Fixture starts up an OCPP1.6 central system and provides access to the connection of the charge point that connects to it. This reference can be used to send OCPP messages initiated by the central system and to receive and validate messages from the charge point. It requires the fixtures central_system_v16 and test_controller and starts the test_controller immediately.
|
||||
- **charge_point_v201**: Fixture starts up an OCPP2.0.1 central system and provides access to the connection of the charge point that connects to it. This reference can be used to send OCPP messages initiated by the central system and to receive and validate messages from the charge point. It requires the fixtures central_system_v16 and test_controller and starts the test_controller immediately.
|
||||
- **test_utility**: Utility fixture that contains the OCPP message history, the validation mode (STRICT, EASY) and it can keep track of forbidden OCPP messages (Actions) (ones that cause a test case to fail if they are received)
|
||||
- **ftp_server**: This fixture creates a temporary directory and starts a local ftp server connected to that directory. The temporary directory is deleted after the test. It is used for Diagnostics and Logfiles
|
||||
|
||||
#### Configuration Fixtures:
|
||||
- **test_config**: This fixture is of type OcppTestConfiguration and it specifies some data that are required or can be configured for testing OCPP. If you don't override this fixture, it initiializes to some default information that is required to set up other fixtures (e.g. ChargePointId, CSMS Port). You can implement this fixture yourself in order to be able to include this information in your test cases.
|
||||
- **ocpp_config** _Overrides_ the core_util's `ocpp_config` fixture. Requires the `test_config` fixture to extract required OCPP configuration.
|
||||
|
||||
An important function that you will frequently use when writing test cases is the **wait_for_and_validate** function inside [charge_point_utils.py](src/everest/testing/ocpp_utils/charge_point_utils.py). This method waits for an expected message specified by the message_type, the action and the payload to be received. It also considers the test case meta_data that contains the message history, the validation mode and forbidden actions.
|
||||
|
||||
|
||||
### pytest markers
|
||||
|
||||
- **ocpp_version**: Can be "ocpp1.6" or "ocpp2.0.1" and is used to setup EVerest and the central system for the specific OCPP version
|
||||
- **ocpp_config**: Specification of the .json OCPP config file. Used in `ocpp_config` fixture and used as template configuration (if not specified, the OCPP config as specified in the EVerest configuration is used)
|
||||
- **ocpp_config_adaptions**: Specification of the .json OCPP config file. Used in `ocpp_config` fixture and used as template configuration (if not specified, the OCPP config as specified in the EVerest configuration is used)
|
||||
- **inject_csms_mock**: (currently only OCPP 2.0.1) If set, the `central_system_v201` will wrap any csms handler method into an unittest mock. In particular, this allows changing the CSMS behavior even after the chargepoint is started by setting side effects of the mock. See `everest.testing.ocpp_utils.charge_point_v201.inject_csms_v201_mock` docstring for an example.
|
||||
- **csms_tls**: Enable/disable TLS for the CSMS websocket server. If given without arguments, enables TLS. First argument can be `False` to explicitly disable TLS. Further optional keyword arguments `certificate`, `private_key`,`passphrase`, `root_ca` , and `verify_client_certificate` allow to overwrite SSL context options.
|
||||
- **ocpp_config_adaptions**: Can be given instances of `OCPPConfigAdjustmentStrategy` as positional arguments which will be applied to the resulting OCPP configuration.
|
||||
- **custom_central_system**: Can be given a instance of `CentralSystem` as the first positional argument which will use that instance as a central system for all fixtures that require a `central_system`.
|
||||
|
||||
|
||||
## Add a conftest.py
|
||||
|
||||
The test_controller fixture and inherently also the charge_point_v16 and charge_point_v201 require information about the directory of the EVerest application and libocpp. Those can be specified within a conftest.py. Within the conftest.py you could also override the test_config fixture for your specific setup.
|
||||
|
||||
## Set markers and override fixture to configure instances
|
||||
|
||||
The `everest_core` fixture utilizes the several configuration fixtures to configure the running instances. In order to adjust
|
||||
the configuration you can
|
||||
- set respective pytest markers to adjust the configuration for a single test / test class
|
||||
- override the specific fixtures to adjust the configuration for a whole test suite
|
||||
|
||||
Note: When overriding a fixture, be careful which pytest markers might be ignored as used by the overridden fixture!
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
|
||||
from everest.testing.core_utils.fixtures import *
|
||||
from everest.testing.ocpp_utils.fixtures import ocpp_config
|
||||
|
||||
@pytest.fixture
|
||||
def test_config(request) -> OcppTestConfiguration:
|
||||
# some code generating a customized OCPP test config
|
||||
...
|
||||
return custom_test_config
|
||||
|
||||
@pytest.fixture
|
||||
def core_config(request) -> EverestEnvironmentCoreConfiguration:
|
||||
# some code generating a customized EveresetCore test config
|
||||
# e.g. useful to point to local Everest configuration files
|
||||
...
|
||||
return custom_core_config
|
||||
|
||||
|
||||
@pytest.mark.probe_module
|
||||
class TestMyEverestModule:
|
||||
# ... pytest test suite that uses for all test the probe module
|
||||
|
||||
def test_a(self, test_controller):
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
_Note_: The "*" import from `core_utils.fixtures` may ensure backwards compatibility to automatically load new default fixtures in the future!
|
||||
|
||||
|
||||
## Install
|
||||
|
||||
In order to use the provided fixtures within your test cases, a successful build of EVerest is required. Refer to https://github.com/EVerest/EVerest for this.
|
||||
|
||||
An MQTT broker needs to run on your system in order to start the test cases including EVerest. Docker can be used for this. Refer to https://everest.github.io/nightly/tutorials/docker_setup.html in order to set this up.
|
||||
|
||||
Install this package using
|
||||
|
||||
```bash
|
||||
python3 -m pip install .
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Have a look at [example_tests.py](examples/tests.py). In this file you can find and run one OCPP1.6 and one OCPP2.0.1 test case. These test cases will help you to get familiar with the fixtures provided in this package. You need a successful build of [EVerest](https://github.com/EVerest/EVerest) on your development machine in order to run the tests.
|
||||
|
||||
You can run these tests using
|
||||
|
||||
```bash
|
||||
cd examples
|
||||
python3 -m pytest tests.py --everest-prefix <path-to-EVerest>/build/dist/ --libocpp <path-to-libocpp> --log-cli-level=DEBUG
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
This file was generate using [venv](https://docs.python.org/3/library/venv.html) and [pip-licenses](https://pypi.org/project/pip-licenses/).
|
||||
|
||||
```bash
|
||||
python3 -m venv everest-testing-venv
|
||||
source everest-testing-venv/bin/activate
|
||||
```
|
||||
|
||||
Change into everest-utils/everest-testing and execute
|
||||
|
||||
```bash
|
||||
python3 -m pip install .
|
||||
python3 -m pip install pip-licenses
|
||||
pip-licenses --format=markdown --order=license --ignore-packages everest-testing
|
||||
```
|
||||
|
||||
| Name | Version | License |
|
||||
|-----------------|---------|-----------------------------------------------------------------|
|
||||
| pyOpenSSL | 23.0.0 | Apache Software License |
|
||||
| pytest-asyncio | 0.21.0 | Apache Software License |
|
||||
| cryptography | 39.0.2 | Apache Software License; BSD License |
|
||||
| packaging | 23.0 | Apache Software License; BSD License |
|
||||
| python-dateutil | 2.8.2 | Apache Software License; BSD License |
|
||||
| pycparser | 2.21 | BSD License |
|
||||
| websockets | 10.4 | BSD License |
|
||||
| paho-mqtt | 1.6.1 | Eclipse Public License v2.0 / Eclipse Distribution License v1.0 |
|
||||
| attrs | 22.2.0 | MIT License |
|
||||
| cffi | 1.15.1 | MIT License |
|
||||
| exceptiongroup | 1.1.1 | MIT License |
|
||||
| iniconfig | 2.0.0 | MIT License |
|
||||
| jsonschema | 4.17.3 | MIT License |
|
||||
| ocpp | 0.17.0 | MIT License |
|
||||
| pluggy | 1.0.0 | MIT License |
|
||||
| pyftpdlib | 1.5.7 | MIT License |
|
||||
| pyrsistent | 0.19.3 | MIT License |
|
||||
| pytest | 7.2.2 | MIT License |
|
||||
| six | 1.16.0 | MIT License |
|
||||
| tomli | 2.0.1 | MIT License |
|
||||
|
||||
This list was generated at 2023-03-22.
|
||||
@@ -0,0 +1,108 @@
|
||||
active_modules:
|
||||
iso15118_car:
|
||||
module: JsCarV2G
|
||||
config_implementation:
|
||||
main:
|
||||
stack_implementation: RISE-V2G
|
||||
mqtt_base_path: everest_external/iso15118/ev
|
||||
device: auto
|
||||
connector_1:
|
||||
module: EvseManager
|
||||
config_module:
|
||||
connector_id: 1
|
||||
three_phases: true
|
||||
has_ventilation: true
|
||||
country_code: DE
|
||||
rcd_enabled: true
|
||||
evse_id: '1'
|
||||
connections:
|
||||
bsp:
|
||||
- module_id: yeti_driver
|
||||
implementation_id: board_support
|
||||
powermeter_grid_side:
|
||||
- module_id: yeti_driver
|
||||
implementation_id: powermeter
|
||||
yeti_driver:
|
||||
module: JsYetiSimulator
|
||||
slac:
|
||||
module: JsSlacSimulator
|
||||
car_simulator:
|
||||
module: JsCarSimulator
|
||||
config_module:
|
||||
auto_enable: true
|
||||
connector_id: 1
|
||||
auto_enable: true
|
||||
connections:
|
||||
simulation_control:
|
||||
- module_id: yeti_driver
|
||||
implementation_id: yeti_simulation_control
|
||||
ev:
|
||||
- module_id: iso15118_car
|
||||
implementation_id: ev
|
||||
slac:
|
||||
- module_id: slac
|
||||
implementation_id: ev
|
||||
ocpp:
|
||||
module: OCPP
|
||||
config_module:
|
||||
ChargePointConfigPath: ocpp16-config.json
|
||||
UserConfigPath: user_config.json
|
||||
EnableExternalWebsocketControl: true
|
||||
connections:
|
||||
evse_manager:
|
||||
- module_id: connector_1
|
||||
implementation_id: evse
|
||||
reservation:
|
||||
- module_id: auth
|
||||
implementation_id: reservation
|
||||
auth:
|
||||
- module_id: auth
|
||||
implementation_id: main
|
||||
system:
|
||||
- module_id: system
|
||||
implementation_id: main
|
||||
auth:
|
||||
module: Auth
|
||||
config_module:
|
||||
connection_timeout: 20
|
||||
connections:
|
||||
token_provider:
|
||||
- module_id: token_provider_manual
|
||||
implementation_id: main
|
||||
- module_id: ocpp
|
||||
implementation_id: auth_provider
|
||||
token_validator:
|
||||
- module_id: ocpp
|
||||
implementation_id: auth_validator
|
||||
evse_manager:
|
||||
- module_id: connector_1
|
||||
implementation_id: evse
|
||||
token_provider_manual:
|
||||
module: JsDummyTokenProviderManual
|
||||
connections: {}
|
||||
config_implementation:
|
||||
main:
|
||||
token: '123'
|
||||
type: dummy
|
||||
energy_manager:
|
||||
module: EnergyManager
|
||||
connections:
|
||||
energy_trunk:
|
||||
- module_id: grid_connection_point
|
||||
implementation_id: energy_grid
|
||||
grid_connection_point:
|
||||
module: EnergyNode
|
||||
config_module:
|
||||
fuse_limit_A: 63.0
|
||||
phase_count: 3
|
||||
connections:
|
||||
price_information: []
|
||||
energy_consumer:
|
||||
- module_id: connector_1
|
||||
implementation_id: energy_grid
|
||||
powermeter:
|
||||
- module_id: yeti_driver
|
||||
implementation_id: powermeter
|
||||
system:
|
||||
module: System
|
||||
x-module-layout: {}
|
||||
@@ -0,0 +1,171 @@
|
||||
active_modules:
|
||||
iso15118_charger:
|
||||
module: PyJosev
|
||||
config_module:
|
||||
device: auto
|
||||
supported_DIN70121: false
|
||||
iso15118_car:
|
||||
module: JsCarV2G
|
||||
config_implementation:
|
||||
main:
|
||||
stack_implementation: RISE-V2G
|
||||
mqtt_base_path: everest_external/iso15118/ev
|
||||
device: auto
|
||||
connector_1:
|
||||
module: EvseManager
|
||||
config_module:
|
||||
connector_id: 1
|
||||
three_phases: true
|
||||
has_ventilation: true
|
||||
country_code: DE
|
||||
rcd_enabled: true
|
||||
evse_id: "1"
|
||||
session_logging: true
|
||||
session_logging_xml: false
|
||||
ac_hlc_enabled: false
|
||||
ac_hlc_use_5percent: false
|
||||
ac_enforce_hlc: false
|
||||
connections:
|
||||
bsp:
|
||||
- module_id: yeti_driver_1
|
||||
implementation_id: board_support
|
||||
powermeter_grid_side:
|
||||
- module_id: yeti_driver_1
|
||||
implementation_id: powermeter
|
||||
slac:
|
||||
- module_id: slac
|
||||
implementation_id: evse
|
||||
hlc:
|
||||
- module_id: iso15118_charger
|
||||
implementation_id: charger
|
||||
connector_2:
|
||||
module: EvseManager
|
||||
config_module:
|
||||
connector_id: 2
|
||||
three_phases: true
|
||||
has_ventilation: true
|
||||
country_code: DE
|
||||
rcd_enabled: true
|
||||
evse_id: "2"
|
||||
session_logging: true
|
||||
session_logging_xml: false
|
||||
ac_hlc_enabled: false
|
||||
ac_hlc_use_5percent: false
|
||||
ac_enforce_hlc: false
|
||||
connections:
|
||||
bsp:
|
||||
- module_id: yeti_driver_2
|
||||
implementation_id: board_support
|
||||
powermeter_grid_side:
|
||||
- module_id: yeti_driver_2
|
||||
implementation_id: powermeter
|
||||
slac:
|
||||
- module_id: slac
|
||||
implementation_id: evse
|
||||
hlc:
|
||||
- module_id: iso15118_charger
|
||||
implementation_id: charger
|
||||
yeti_driver_1:
|
||||
module: JsYetiSimulator
|
||||
yeti_driver_2:
|
||||
module: JsYetiSimulator
|
||||
slac:
|
||||
module: JsSlacSimulator
|
||||
car_simulator_1:
|
||||
module: JsCarSimulator
|
||||
config_module:
|
||||
connector_id: 1
|
||||
auto_enable: true
|
||||
auto_exec: false
|
||||
auto_exec_commands: sleep 1;iec_wait_pwr_ready;sleep 1;draw_power_regulated 16,3;sleep 30;unplug
|
||||
connections:
|
||||
simulation_control:
|
||||
- module_id: yeti_driver_1
|
||||
implementation_id: yeti_simulation_control
|
||||
ev:
|
||||
- module_id: iso15118_car
|
||||
implementation_id: ev
|
||||
slac:
|
||||
- module_id: slac
|
||||
implementation_id: ev
|
||||
car_simulator_2:
|
||||
module: JsCarSimulator
|
||||
config_module:
|
||||
connector_id: 2
|
||||
auto_enable: true
|
||||
auto_exec: false
|
||||
connections:
|
||||
simulation_control:
|
||||
- module_id: yeti_driver_2
|
||||
implementation_id: yeti_simulation_control
|
||||
ev:
|
||||
- module_id: iso15118_car
|
||||
implementation_id: ev
|
||||
slac:
|
||||
- module_id: slac
|
||||
implementation_id: ev
|
||||
ocpp:
|
||||
module: OCPP201
|
||||
config_module:
|
||||
ChargePointConfigPath: config.json
|
||||
connections:
|
||||
evse_manager:
|
||||
- module_id: connector_1
|
||||
implementation_id: evse
|
||||
- module_id: connector_2
|
||||
implementation_id: evse
|
||||
system:
|
||||
- module_id: system
|
||||
implementation_id: main
|
||||
auth:
|
||||
module: Auth
|
||||
config_module:
|
||||
connection_timeout: 30
|
||||
selection_algorithm: PlugEvents
|
||||
connections:
|
||||
token_provider:
|
||||
- module_id: ocpp
|
||||
implementation_id: auth_provider
|
||||
- module_id: token_provider_manual
|
||||
implementation_id: main
|
||||
token_validator:
|
||||
- module_id: ocpp
|
||||
implementation_id: auth_validator
|
||||
evse_manager:
|
||||
- module_id: connector_1
|
||||
implementation_id: evse
|
||||
- module_id: connector_2
|
||||
implementation_id: evse
|
||||
token_provider_manual:
|
||||
module: JsDummyTokenProviderManual
|
||||
energy_manager:
|
||||
module: EnergyManager
|
||||
connections:
|
||||
energy_trunk:
|
||||
- module_id: grid_connection_point
|
||||
implementation_id: energy_grid
|
||||
grid_connection_point:
|
||||
module: EnergyNode
|
||||
config_module:
|
||||
fuse_limit_A: 40.0
|
||||
phase_count: 3
|
||||
connections:
|
||||
price_information: []
|
||||
energy_consumer:
|
||||
- module_id: connector_1
|
||||
implementation_id: energy_grid
|
||||
- module_id: connector_2
|
||||
implementation_id: energy_grid
|
||||
powermeter:
|
||||
- module_id: yeti_driver_1
|
||||
implementation_id: powermeter
|
||||
api:
|
||||
module: API
|
||||
connections:
|
||||
evse_manager:
|
||||
- module_id: connector_1
|
||||
implementation_id: evse
|
||||
system:
|
||||
module: System
|
||||
|
||||
x-module-layout: {}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"Internal": {
|
||||
"ChargePointId": "cp001",
|
||||
"CentralSystemURI": "127.0.0.1:9000/cp001",
|
||||
"ChargeBoxSerialNumber": "cp001",
|
||||
"ChargePointModel": "Yeti",
|
||||
"ChargePointVendor": "Pionix",
|
||||
"FirmwareVersion": "0.1",
|
||||
"LogMessages": true
|
||||
},
|
||||
"Core": {
|
||||
"AllowOfflineTxForUnknownId": true,
|
||||
"AuthorizeRemoteTxRequests": true,
|
||||
"AuthorizationCacheEnabled": true,
|
||||
"ClockAlignedDataInterval": 900,
|
||||
"ConnectionTimeOut": 10,
|
||||
"ConnectorPhaseRotation": "0.RST,1.RST",
|
||||
"GetConfigurationMaxKeys": 100,
|
||||
"HeartbeatInterval": 86400,
|
||||
"LocalAuthorizeOffline": false,
|
||||
"LocalPreAuthorize": false,
|
||||
"MeterValuesAlignedData": "Energy.Active.Import.Register",
|
||||
"MeterValuesSampledData": "Energy.Active.Import.Register",
|
||||
"MeterValueSampleInterval": 0,
|
||||
"NumberOfConnectors": 1,
|
||||
"ResetRetries": 1,
|
||||
"StopTransactionOnEVSideDisconnect": true,
|
||||
"StopTransactionOnInvalidId": true,
|
||||
"StopTxnAlignedData": "Energy.Active.Import.Register",
|
||||
"StopTxnSampledData": "Energy.Active.Import.Register",
|
||||
"SupportedFeatureProfiles": "Core,FirmwareManagement,RemoteTrigger,Reservation,LocalAuthListManagement,SmartCharging",
|
||||
"TransactionMessageAttempts": 3,
|
||||
"TransactionMessageRetryInterval": 1,
|
||||
"UnlockConnectorOnEVSideDisconnect": true
|
||||
},
|
||||
"FirmwareManagement": {
|
||||
"SupportedFileTransferProtocols": "FTP"
|
||||
},
|
||||
"Security": {
|
||||
"AuthorizationKey": "AABBCCDDEEFFGGHH",
|
||||
"SecurityProfile": 0,
|
||||
"CpoName": "Pionix",
|
||||
"AdditionalRootCertificateCheck": false
|
||||
},
|
||||
"LocalAuthListManagement": {
|
||||
"LocalAuthListEnabled": true,
|
||||
"LocalAuthListMaxLength": 42,
|
||||
"SendLocalListMaxLength": 42
|
||||
},
|
||||
"Reservation": {
|
||||
"ReserveConnectorZeroSupported": true
|
||||
},
|
||||
"SmartCharging": {
|
||||
"ChargeProfileMaxStackLevel": 42,
|
||||
"ChargingScheduleAllowedChargingRateUnit": "Current,Power",
|
||||
"ChargingScheduleMaxPeriods": 42,
|
||||
"MaxChargingProfilesInstalled": 42
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2020 - 2023 Pionix GmbH and Contributors to EVerest
|
||||
|
||||
import shutil
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption("--everest-prefix", action="store", default="~/checkout/everest-workspace/EVerest",
|
||||
help="EVerest path; default = '~/checkout/everest-workspace/EVerest'")
|
||||
parser.addoption("--libocpp", action="store", default="~/checkout/everest-workspace/libocpp",
|
||||
help="libocpp path; default = '~/checkout/everest-workspace/libocpp'")
|
||||
|
||||
def pytest_configure(config):
|
||||
everest_prefix = config.getoption("--everest-prefix")
|
||||
shutil.copy("conf/ocpp16-config.json", f"{everest_prefix}/share/everest/modules/OCPP")
|
||||
@@ -0,0 +1,192 @@
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from ocpp.v201 import call_result, call
|
||||
from ocpp.v201.datatypes import SetVariableResultType, IdTokenType
|
||||
from ocpp.v201.enums import SetVariableStatusType, IdTokenType as IdTokenTypeEnum, ClearCacheStatusType, ConnectorStatusType, RequestStartStopStatusType
|
||||
|
||||
from everest.testing.core_utils.controller.everest_test_controller import EverestTestController
|
||||
from everest.testing.core_utils.controller.test_controller_interface import TestController
|
||||
from everest.testing.core_utils.fixtures import *
|
||||
# noinspection PyUnresolvedReferences
|
||||
from everest.testing.ocpp_utils.fixtures import test_utility, charge_point_v16, central_system, test_config, ocpp_config, charge_point, ocpp_version
|
||||
from everest.testing.ocpp_utils.charge_point_utils import wait_for_and_validate, OcppTestConfiguration, TestUtility
|
||||
from everest.testing.ocpp_utils.charge_point_v201 import ChargePoint201
|
||||
from everest.testing.ocpp_utils.charge_point_v16 import ChargePoint16
|
||||
|
||||
|
||||
def validate_status_notification_201(meta_data, msg, exp_payload):
|
||||
return msg.payload['connectorStatus'] == exp_payload.connector_status and \
|
||||
msg.payload['evseId'] == exp_payload.evse_id and \
|
||||
msg.payload['connectorId'] == exp_payload.connector_id
|
||||
|
||||
|
||||
@ pytest.mark.asyncio
|
||||
@pytest.mark.ocpp_version("ocpp1.6")
|
||||
@pytest.mark.everest_core_config("conf/everest-config-ocpp16.yaml")
|
||||
async def test_ocpp_16(test_config: OcppTestConfiguration, charge_point_v16: ChargePoint16, test_controller: TestController, test_utility: TestUtility):
|
||||
await charge_point_v16.get_configuration_req(key=["AuthorizeRemoteTxRequests"])
|
||||
await charge_point_v16.change_configuration_req(key="MeterValueSampleInterval", value="10")
|
||||
|
||||
# send RemoteStartTransaction.req
|
||||
await charge_point_v16.remote_start_transaction_req(id_tag="DEADBEEF", connector_id=1)
|
||||
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v16, "RequestStartTransactio", call_result.RequestStartTransactionPayload(status=RequestStartStopStatusType.accepted))
|
||||
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v16, "StatusNotification", {"connectorId": 1, "status": "Preparing"})
|
||||
|
||||
test_controller.plug_in()
|
||||
|
||||
# expect StartTransaction.req
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v16, "StartTransaction", {
|
||||
"connectorId": 1, "idTag": "DEADBEEF", "meterStart": 0})
|
||||
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v16, "StatusNotification", {"connectorId": 1, "status": "Charging"})
|
||||
|
||||
assert await charge_point_v16.remote_stop_transaction_req(transaction_id=1)
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v16, "RemoteStopTransaction", {"status": "Accepted"})
|
||||
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v16, "StopTransaction", {"reason": "Remote"})
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v16, "StatusNotification", {"connectorId": 1, "status": "Finishing"})
|
||||
|
||||
test_controller.plug_out()
|
||||
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v16, "StatusNotification", {"connectorId": 1, "status": "Available"})
|
||||
|
||||
|
||||
@ pytest.mark.asyncio
|
||||
@pytest.mark.ocpp_version("ocpp2.0.1")
|
||||
@pytest.mark.everest_core_config("conf/everest-config-ocpp201.yaml")
|
||||
async def test_ocpp_201(charge_point_v201: ChargePoint201, test_controller: EverestTestController, test_utility: TestUtility):
|
||||
"""This test case tests some requirements around AuthorizationCache of OCPP2.0.1
|
||||
|
||||
Args:
|
||||
charge_point_v201 (ChargePoint201): this fixture starts up a OCPP2.0.1 CSMS and EVerest connection to this CSMS using OCPP. The reference can be used to send and receive messages over OCPP
|
||||
test_controller (TestController): this fixture is used to control the simulation
|
||||
test_utility (TestUtility): this fixture carries meta data of the test case that can be used for validations
|
||||
"""
|
||||
|
||||
# prepare data for the test
|
||||
evse_id = 1
|
||||
connector_id = 1
|
||||
|
||||
# Enable AuthCacheCtrlr
|
||||
r: call_result.SetVariablesPayload = await charge_point_v201.set_config_variables_req("AuthCacheCtrlr", "Enabled", "true")
|
||||
set_variable_result: SetVariableResultType = SetVariableResultType(
|
||||
**r.set_variable_result[0])
|
||||
assert set_variable_result.attribute_status == SetVariableStatusType.accepted
|
||||
|
||||
# Enable LocalPreAuthorize
|
||||
r: call_result.SetVariablesPayload = await charge_point_v201.set_config_variables_req("AuthCtrlr", "LocalPreAuthorize", "true")
|
||||
set_variable_result: SetVariableResultType = SetVariableResultType(
|
||||
**r.set_variable_result[0])
|
||||
assert set_variable_result.attribute_status == SetVariableStatusType.accepted
|
||||
|
||||
# Set AuthCacheLifeTime
|
||||
r: call_result.SetVariablesPayload = await charge_point_v201.set_config_variables_req("AuthCacheCtrlr", "LifeTime", "86400")
|
||||
set_variable_result: SetVariableResultType = SetVariableResultType(
|
||||
**r.set_variable_result[0])
|
||||
assert set_variable_result.attribute_status == SetVariableStatusType.accepted
|
||||
|
||||
# Clear cache
|
||||
r: call_result.ClearCachePayload = await charge_point_v201.clear_cache_req()
|
||||
assert r.status == ClearCacheStatusType.accepted
|
||||
|
||||
test_controller.swipe("DEADBEEF")
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v201, "Authorize", call.AuthorizePayload(
|
||||
id_token=IdTokenType(
|
||||
id_token="DEADBEEF",
|
||||
type=IdTokenTypeEnum.iso14443
|
||||
)
|
||||
))
|
||||
|
||||
test_controller.plug_in()
|
||||
# eventType=Started
|
||||
await wait_for_and_validate(test_utility, charge_point_v201, "TransactionEvent", {"eventType": "Started"})
|
||||
test_utility.messages.clear()
|
||||
test_controller.plug_out()
|
||||
# eventType=Ended
|
||||
await wait_for_and_validate(test_utility, charge_point_v201, "TransactionEvent", {"eventType": "Ended"})
|
||||
|
||||
test_utility.messages.clear()
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# because LocalPreAuthorize is true we dont expect an Authorize.req this time
|
||||
test_utility.forbidden_actions.append("Authorize")
|
||||
|
||||
test_controller.swipe("DEADBEEF")
|
||||
test_controller.plug_in()
|
||||
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v201, "StatusNotification",
|
||||
call.StatusNotificationPayload(datetime.now().isoformat(),
|
||||
ConnectorStatusType.occupied, evse_id, connector_id),
|
||||
validate_status_notification_201)
|
||||
|
||||
# because LocalPreAuthorize is true we dont expect an authorize here
|
||||
r: call.TransactionEventPayload = call.TransactionEventPayload(**await wait_for_and_validate(test_utility, charge_point_v201,
|
||||
"TransactionEvent", {"eventType": "Started"}))
|
||||
|
||||
# Disable LocalPreAuthorize
|
||||
r: call_result.SetVariablesPayload = await charge_point_v201.set_config_variables_req("AuthCtrlr", "LocalPreAuthorize", "false")
|
||||
set_variable_result: SetVariableResultType = SetVariableResultType(
|
||||
**r.set_variable_result[0])
|
||||
assert set_variable_result.attribute_status == SetVariableStatusType.accepted
|
||||
|
||||
# Set AuthCacheLifeTime to 1s
|
||||
r: call_result.SetVariablesPayload = await charge_point_v201.set_config_variables_req("AuthCacheCtrlr", "LifeTime", "1")
|
||||
set_variable_result: SetVariableResultType = SetVariableResultType(
|
||||
**r.set_variable_result[0])
|
||||
assert set_variable_result.attribute_status == SetVariableStatusType.accepted
|
||||
|
||||
test_utility.messages.clear()
|
||||
test_controller.plug_out()
|
||||
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v201, "StatusNotification",
|
||||
call.StatusNotificationPayload(datetime.now().isoformat(),
|
||||
ConnectorStatusType.available, evse_id, connector_id),
|
||||
validate_status_notification_201)
|
||||
|
||||
# eventType=Ended
|
||||
await wait_for_and_validate(test_utility, charge_point_v201,"TransactionEvent", {"eventType": "Ended"})
|
||||
|
||||
test_utility.forbidden_actions.clear()
|
||||
|
||||
test_controller.swipe("DEADBEEF")
|
||||
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v201, "Authorize", call.AuthorizePayload(
|
||||
id_token=IdTokenType(
|
||||
id_token="DEADBEEF",
|
||||
type=IdTokenTypeEnum.iso14443
|
||||
)
|
||||
))
|
||||
|
||||
# Enable LocalPreAuthorize
|
||||
r: call_result.SetVariablesPayload = await charge_point_v201.set_config_variables_req("AuthCtrlr", "LocalPreAuthorize", "true")
|
||||
set_variable_result: SetVariableResultType = SetVariableResultType(
|
||||
**r.set_variable_result[0])
|
||||
assert set_variable_result.attribute_status == SetVariableStatusType.accepted
|
||||
|
||||
test_controller.plug_in()
|
||||
# eventType=Started
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v201, "TransactionEvent", {"eventType": "Started"})
|
||||
test_utility.messages.clear()
|
||||
test_controller.plug_out()
|
||||
# eventType=Ended
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v201,"TransactionEvent", {"eventType": "Ended"})
|
||||
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v201, "StatusNotification",
|
||||
call.StatusNotificationPayload(datetime.now().isoformat(),
|
||||
ConnectorStatusType.available, evse_id, connector_id),
|
||||
validate_status_notification_201)
|
||||
|
||||
# AuthCacheLifeTime expired so sending Authorize.req
|
||||
test_controller.swipe("DEADBEEF")
|
||||
assert await wait_for_and_validate(test_utility, charge_point_v201, "Authorize", call.AuthorizePayload(
|
||||
id_token=IdTokenType(
|
||||
id_token="DEADBEEF",
|
||||
type=IdTokenTypeEnum.iso14443
|
||||
)
|
||||
))
|
||||
@@ -0,0 +1,9 @@
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools>=42",
|
||||
"wheel"
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.autopep8]
|
||||
max_line_length = 120
|
||||
@@ -0,0 +1,34 @@
|
||||
[metadata]
|
||||
name = everest-testing
|
||||
version = attr: everest.testing.__version__
|
||||
author = Piet Gömpel
|
||||
author_email = piet.goempel@pionix.de
|
||||
description = Utilities for testing EVerest
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
url = https://github.com/EVerest/everest-utils
|
||||
classifiers =
|
||||
Programming Language :: Python :: 3
|
||||
License :: OSI Approved :: Apache Software License
|
||||
Operating System :: OS Independent
|
||||
|
||||
[options]
|
||||
install_requires =
|
||||
pytest ~=9.0
|
||||
pytest-asyncio ~=1.3
|
||||
python-dateutil ~=2.9
|
||||
paho-mqtt >=2.0
|
||||
pyftpdlib ~=2.2
|
||||
ocpp ==2.1.0
|
||||
websockets ~=13.1
|
||||
pyOpenSSL >=23.2
|
||||
pyyaml ~=6.0
|
||||
cryptography ~=41.0
|
||||
pytest-xdist ~=3.0
|
||||
pytest-timeout ~=2.0
|
||||
pytest-html ~=4.0
|
||||
|
||||
package_dir =
|
||||
= src
|
||||
|
||||
python_requires = >=3.8
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = '0.7.3'
|
||||
@@ -0,0 +1,8 @@
|
||||
from ._configuration.everest_configuration_strategies.everest_configuration_strategy import \
|
||||
EverestConfigAdjustmentStrategy # flake8: noqa
|
||||
from ._configuration.libocpp_configuration_helper import OCPPConfigAdjustmentStrategy, \
|
||||
OCPPConfigAdjustmentStrategyWrapper # flake8: noqa
|
||||
from .network_isolation import NetworkIsolationStrategy, NetworkIsolationPlugin, \
|
||||
get_worker_interface, WORKER_INTERFACE_ENV # flake8: noqa
|
||||
|
||||
__all__ = ["common", "everest_core", "fixtures", "network_isolation", "probe_module"]
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
See base class `EverestConfigAdjustmentStrategy`
|
||||
|
||||
"""
|
||||
@@ -0,0 +1,17 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class EverestConfigAdjustmentStrategy(ABC):
|
||||
""" Strategy that manipulates a (parsed) EVerest config when called.
|
||||
|
||||
Used to build up / adapt EVerest configurations for tests.
|
||||
|
||||
Adjustments can be collected during the configuration setup process. The Everst core class then applies all
|
||||
configuration adjustments.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def adjust_everest_configuration(self, config: Dict) -> Dict:
|
||||
""" Adjusts the provided configuration by making a (deep) copy and returning the adjusted configuration. """
|
||||
pass
|
||||
@@ -0,0 +1,89 @@
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
from everest.testing.core_utils._configuration.everest_configuration_strategies.everest_configuration_strategy import \
|
||||
EverestConfigAdjustmentStrategy
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvseSecurityModuleConfiguration:
|
||||
csms_ca_bundle: Optional[str] = None
|
||||
mf_ca_bundle: Optional[str] = None
|
||||
mo_ca_bundle: Optional[str] = None
|
||||
v2g_ca_bundle: Optional[str] = None
|
||||
csms_leaf_cert_directory: Optional[str] = None
|
||||
csms_leaf_key_directory: Optional[str] = None
|
||||
secc_leaf_cert_directory: Optional[str] = None
|
||||
secc_leaf_key_directory: Optional[str] = None
|
||||
private_key_password: Optional[str] = None
|
||||
|
||||
|
||||
class EvseSecurityModuleConfigurationStrategy(EverestConfigAdjustmentStrategy):
|
||||
""" Adjusts the Evse security module configuration in the Everest configuration merging a provided configuration into it (if provided) and adapt
|
||||
all paths relative to a certificate target directory (if provided).
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
configuration: Optional[EvseSecurityModuleConfiguration] = None,
|
||||
target_certificates_directory: Optional[Path] = None,
|
||||
source_certificates_directory: Optional[Path] = None,
|
||||
module_id: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
|
||||
Args:
|
||||
configuration: module configuration. If provided. this will be merged into the template configuration (meaning None values are ignored/taken from the originally provided Everest configuration)
|
||||
module_id: Id of security module; if None, auto-detected by module type "EvseSecurity"
|
||||
target_certificates_directory: If provided, all configured certificate directories/paths will be changed to point into this folder
|
||||
source_certificates_directory: If provided, configured certificate directories/paths will be considered relative to this directory; each relative part is appended to the corresponding target paths
|
||||
"""
|
||||
self._security_module_id = module_id
|
||||
self._configuration = configuration
|
||||
self._target_certificates_directory = target_certificates_directory
|
||||
self._source_certificates_directory = source_certificates_directory
|
||||
|
||||
def _move_paths(self, module_config: Dict):
|
||||
def _move(p: Union[str, Path]):
|
||||
if self._source_certificates_directory and Path(p).is_relative_to(self._source_certificates_directory):
|
||||
p = Path(p).relative_to(self._source_certificates_directory)
|
||||
return str(self._target_certificates_directory / p)
|
||||
|
||||
for k in {"csms_ca_bundle",
|
||||
"mf_ca_bundle",
|
||||
"mo_ca_bundle",
|
||||
"v2g_ca_bundle",
|
||||
"csms_leaf_cert_directory",
|
||||
"csms_leaf_key_directory",
|
||||
"secc_leaf_cert_directory",
|
||||
"secc_leaf_key_directory"}:
|
||||
if module_config["config_module"].get(k):
|
||||
module_config["config_module"][k] = _move(module_config["config_module"][k])
|
||||
|
||||
def _determine_module_id(self, everest_config: Dict):
|
||||
if self._security_module_id:
|
||||
assert self._security_module_id in everest_config[
|
||||
"active_modules"], f"Module id {self._security_module_id} not found in EVerest configuration"
|
||||
return self._security_module_id
|
||||
else:
|
||||
try:
|
||||
return next(k for k, v in everest_config["active_modules"].items() if v["module"] == "EvseSecurity")
|
||||
except StopIteration:
|
||||
raise ValueError("No EvseSecurity module found in EVerest configuration")
|
||||
|
||||
def adjust_everest_configuration(self, everest_config: Dict):
|
||||
|
||||
adjusted_config = deepcopy(everest_config)
|
||||
|
||||
module_cfg = adjusted_config["active_modules"][self._determine_module_id(adjusted_config)]
|
||||
|
||||
if self._configuration:
|
||||
module_cfg["config_module"] = {**module_cfg["config_module"],
|
||||
**{k:v for k,v in asdict(self._configuration).items()
|
||||
if v is not None}}
|
||||
|
||||
if self._target_certificates_directory:
|
||||
self._move_paths(module_cfg)
|
||||
|
||||
return adjusted_config
|
||||
@@ -0,0 +1,37 @@
|
||||
from copy import deepcopy
|
||||
from typing import Dict
|
||||
|
||||
from everest.testing.core_utils._configuration.everest_configuration_strategies.everest_configuration_strategy import \
|
||||
EverestConfigAdjustmentStrategy
|
||||
|
||||
|
||||
class EverestMqttConfigurationAdjustmentStrategy(EverestConfigAdjustmentStrategy):
|
||||
""" Adjusts the Everest configuration by manipulating the "settings" block to use the prober Everest UUID and
|
||||
external prefix.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, everest_uuid: str, mqtt_external_prefix: str):
|
||||
self._everest_uuid = everest_uuid
|
||||
self._mqtt_external_prefix = mqtt_external_prefix
|
||||
|
||||
def _find_jscarv2g_module_ids(self, config: Dict):
|
||||
return [k for k, v in config["active_modules"].items()
|
||||
if v.get("module") == "JsCarV2G"]
|
||||
|
||||
def adjust_everest_configuration(self, config: Dict) -> Dict:
|
||||
adjusted_everest_config = deepcopy(config)
|
||||
adjusted_everest_config["settings"] = {}
|
||||
adjusted_everest_config["settings"]["mqtt_everest_prefix"] = f"everest_{self._everest_uuid}"
|
||||
adjusted_everest_config["settings"]["mqtt_external_prefix"] = self._mqtt_external_prefix
|
||||
adjusted_everest_config["settings"]["telemetry_prefix"] = f"telemetry_{self._everest_uuid}"
|
||||
|
||||
# make sure controller starts with a dynamic port
|
||||
adjusted_everest_config["settings"]["controller_port"] = 0
|
||||
|
||||
for car_module_id in self._find_jscarv2g_module_ids(adjusted_everest_config):
|
||||
adjusted_everest_config["active_modules"][car_module_id]\
|
||||
.setdefault("config_implementation",{})\
|
||||
.setdefault("main", {})["mqtt_prefix"] = self._mqtt_external_prefix
|
||||
|
||||
return adjusted_everest_config
|
||||
@@ -0,0 +1,65 @@
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Union, Dict
|
||||
|
||||
from everest.testing.core_utils.common import OCPPVersion
|
||||
from everest.testing.core_utils._configuration.everest_configuration_strategies.everest_configuration_strategy import EverestConfigAdjustmentStrategy
|
||||
|
||||
|
||||
@dataclass
|
||||
class OCPPModuleConfigurationBase:
|
||||
MessageLogPath: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class OCPPModulePaths16(OCPPModuleConfigurationBase):
|
||||
ChargePointConfigPath: str
|
||||
UserConfigPath: str
|
||||
DatabasePath: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class OCPPModulePaths2X(OCPPModuleConfigurationBase):
|
||||
DeviceModelConfigPath: str
|
||||
CoreDatabasePath: str
|
||||
DeviceModelDatabasePath: str
|
||||
EverestDeviceModelDatabasePath: str
|
||||
|
||||
class OCPPModuleConfigurationStrategy(EverestConfigAdjustmentStrategy):
|
||||
""" Adjusts the Everest configuration by manipulating the OCPP module configuration to use proper (temporary test) paths.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, ocpp_paths: Union[OCPPModulePaths16, OCPPModulePaths2X],
|
||||
ocpp_module_id: str,
|
||||
ocpp_version: OCPPVersion):
|
||||
self._ocpp_paths = ocpp_paths
|
||||
self._ocpp_module_id = ocpp_module_id
|
||||
self._ocpp_version = ocpp_version
|
||||
|
||||
def adjust_everest_configuration(self, everest_config: Dict):
|
||||
""" Changes the provided configuration of the Everest "OCPP" module .
|
||||
|
||||
Creates the TEST_LOGS_DIR if not existent
|
||||
"""
|
||||
|
||||
adjusted_config = deepcopy(everest_config)
|
||||
|
||||
self._verify_module_config(adjusted_config)
|
||||
|
||||
module_config = adjusted_config["active_modules"][self._ocpp_module_id]
|
||||
|
||||
module_config["config_module"] = {**module_config["config_module"],
|
||||
**asdict(self._ocpp_paths)}
|
||||
|
||||
return adjusted_config
|
||||
|
||||
def _verify_module_config(self, everest_config):
|
||||
""" Verify the provided config fits the provided OCPP version """
|
||||
assert "active_modules" in everest_config and self._ocpp_module_id in everest_config[
|
||||
"active_modules"], "OCPP Module is missing from EVerest config"
|
||||
ocpp_module = everest_config["active_modules"][self._ocpp_module_id]["module"]
|
||||
assert (ocpp_module == "OCPP" and self._ocpp_version == OCPPVersion.ocpp16) or (
|
||||
ocpp_module == "OCPP201" and (self._ocpp_version == OCPPVersion.ocpp201 or
|
||||
self._ocpp_version == OCPPVersion.ocpp21)), \
|
||||
f"Invalid OCCP Module {ocpp_module} for provided OCCP version {self._ocpp_version}"
|
||||
@@ -0,0 +1,46 @@
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from everest.testing.core_utils._configuration.everest_configuration_strategies.everest_configuration_strategy import \
|
||||
EverestConfigAdjustmentStrategy
|
||||
|
||||
|
||||
class PersistentStoreConfigurationStrategy(EverestConfigAdjustmentStrategy):
|
||||
""" Adjusts the Everest configuration by manipulating the PersistentStore module configuration to point
|
||||
to the desired (temporary) storage
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
sqlite_db_file_path: Path,
|
||||
module_id: Optional[str] = None):
|
||||
"""
|
||||
|
||||
Args:
|
||||
sqlite_db_file_path: database to be used in configuration
|
||||
module_id: Id of security module; if None, auto-detected by module type "EvseSecurity
|
||||
"""
|
||||
self._module_id = module_id
|
||||
self._sqlite_db_file_path = sqlite_db_file_path
|
||||
|
||||
def _determine_module_id(self, everest_config: Dict):
|
||||
if self._module_id:
|
||||
assert self._module_id in everest_config[
|
||||
"active_modules"], f"Module id {self._module_id} not found in EVerest configuration"
|
||||
return self._module_id
|
||||
else:
|
||||
try:
|
||||
return next(k for k, v in everest_config["active_modules"].items() if v["module"] == "PersistentStore")
|
||||
except StopIteration:
|
||||
raise ValueError("No PersistentStore module found in EVerest configuration")
|
||||
|
||||
def adjust_everest_configuration(self, everest_config: Dict):
|
||||
|
||||
adjusted_config = deepcopy(everest_config)
|
||||
|
||||
module_cfg = adjusted_config["active_modules"][self._determine_module_id(adjusted_config)]
|
||||
|
||||
module_cfg.setdefault("config_module", {})["sqlite_db_file_path"] = str(self._sqlite_db_file_path)
|
||||
|
||||
return adjusted_config
|
||||
@@ -0,0 +1,33 @@
|
||||
from copy import deepcopy
|
||||
from typing import Dict, List
|
||||
|
||||
from everest.testing.core_utils.common import Requirement
|
||||
from everest.testing.core_utils._configuration.everest_configuration_strategies.everest_configuration_strategy import \
|
||||
EverestConfigAdjustmentStrategy
|
||||
|
||||
|
||||
class ProbeModuleConfigurationStrategy(EverestConfigAdjustmentStrategy):
|
||||
""" Adjusts the Everest configuration by adding the probe module into an EVerest config """
|
||||
|
||||
def __init__(self,
|
||||
connections: Dict[str, List[Requirement]],
|
||||
module_id: str = "probe"
|
||||
):
|
||||
self._module_id = module_id
|
||||
self._connections = connections
|
||||
|
||||
def adjust_everest_configuration(self, everest_config: Dict) -> Dict:
|
||||
adjusted_config = deepcopy(everest_config)
|
||||
|
||||
probe_connections = {
|
||||
requirement_id: [{"module_id": requirement.module_id, "implementation_id": requirement.implementation_id}
|
||||
for requirement in requirements_list]
|
||||
for requirement_id, requirements_list in self._connections.items()}
|
||||
|
||||
active_modules = adjusted_config.setdefault("active_modules", {})
|
||||
active_modules[self._module_id] = {
|
||||
'connections': probe_connections,
|
||||
'module': 'ProbeModule'
|
||||
}
|
||||
|
||||
return adjusted_config
|
||||
@@ -0,0 +1,286 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2020 - 2023 Pionix GmbH and Contributors to EVerest
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Union
|
||||
|
||||
import yaml
|
||||
|
||||
from everest.testing.core_utils.common import OCPPVersion
|
||||
from everest.testing.core_utils.everest_core import EverestCore, Requirement
|
||||
from .everest_configuration_strategies.everest_configuration_strategy import \
|
||||
EverestConfigAdjustmentStrategy
|
||||
from .everest_configuration_strategies.evse_security_configuration_strategy import \
|
||||
EvseSecurityModuleConfigurationStrategy, EvseSecurityModuleConfiguration
|
||||
from .everest_configuration_strategies.ocpp_module_configuration_strategy import \
|
||||
OCPPModuleConfigurationStrategy, \
|
||||
OCPPModulePaths16, OCPPModulePaths2X
|
||||
from .everest_configuration_strategies.persistent_store_configuration_strategy import \
|
||||
PersistentStoreConfigurationStrategy
|
||||
from .everest_configuration_strategies.probe_module_configuration_strategy import \
|
||||
ProbeModuleConfigurationStrategy
|
||||
from .libocpp_configuration_helper import \
|
||||
LibOCPP2XConfigurationHelper, LibOCPP16ConfigurationHelper
|
||||
|
||||
|
||||
@dataclass
|
||||
class EverestEnvironmentOCPPConfiguration:
|
||||
ocpp_version: OCPPVersion
|
||||
central_system_port: int
|
||||
central_system_host: str = "127.0.0.1"
|
||||
ocpp_module_id: str = "ocpp"
|
||||
template_ocpp_config: Optional[
|
||||
Path] = None # Path for OCPP config to be used; if not provided, will be determined from everest config
|
||||
device_model_component_config_path: Optional[
|
||||
Path] = None # Path of the OCPP device model json schemas.
|
||||
configuration_strategies: list[OCPPModuleConfigurationStrategy] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EverestEnvironmentEvseSecurityConfiguration:
|
||||
# if true, configuration will be adapted to use temporary certifcates folder, this assumes a "default" file tree structure, cf. the EvseSecurityModuleConfiguration dataclass
|
||||
use_temporary_certificates_folder: bool = True
|
||||
module_id: Optional[str] = None # if None, auto-detected
|
||||
source_certificate_directory: Optional[
|
||||
Path] = None # if provided, this will be copied to temporary path; If none, the certificates of the EVerest directory / installation will be used
|
||||
module_configuration: Optional[
|
||||
# if provided, will be merged into configuration; paths will be adapted if use_temporary_certificates_folder is true
|
||||
EvseSecurityModuleConfiguration] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EverestEnvironmentPersistentStoreConfiguration:
|
||||
# if true, a temporary persistent storage folder will be used
|
||||
use_temporary_folder: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class EverestEnvironmentCoreConfiguration:
|
||||
everest_core_path: Path
|
||||
template_everest_config_path: Union[
|
||||
Path, None] # Underlying EVerest configuration; will be copied temporarily by EverestCore and adjusted;
|
||||
# if none, config is auto-detected by EVerest
|
||||
|
||||
|
||||
@dataclass
|
||||
class EverestEnvironmentProbeModuleConfiguration:
|
||||
connections: Dict[str, List[Requirement]] = field(default_factory=dict)
|
||||
module_id: str = "probe"
|
||||
|
||||
|
||||
class EverestTestEnvironmentSetup:
|
||||
"""
|
||||
Class that prepares the environment of EVerest core and creates the EverestCore instance of a test.
|
||||
|
||||
For this:
|
||||
- receives all settings / configurations required at initialization
|
||||
- calling setup_environment:
|
||||
- creates required temporary paths / file structures
|
||||
- configures EverestCore including adjustments of the EverestCore configuration (injecting temporary paths ...)
|
||||
- sets up special modules (initiates the setup of OCPPlib such as parsing the device model database)
|
||||
- creates the EverestCore instance
|
||||
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class _EverestEnvironmentTemporaryPaths:
|
||||
""" Paths of the temporary configuration files / data """
|
||||
certs_dir: Path # used by both OCPP and evse security
|
||||
ocpp_config_path: Path
|
||||
ocpp_config_file: Path
|
||||
ocpp_user_config_file: Path
|
||||
ocpp_database_dir: Path
|
||||
ocpp_message_log_directory: Path
|
||||
persistent_store_db_path: Path
|
||||
|
||||
def __init__(self,
|
||||
core_config: EverestEnvironmentCoreConfiguration,
|
||||
ocpp_config: Optional[EverestEnvironmentOCPPConfiguration] = None,
|
||||
probe_config: Optional[EverestEnvironmentProbeModuleConfiguration] = None,
|
||||
evse_security_config: Optional[EverestEnvironmentEvseSecurityConfiguration] = None,
|
||||
persistent_store_config: Optional[EverestEnvironmentPersistentStoreConfiguration] = None,
|
||||
standalone_module: Optional[Union[str, List[str]]] = None,
|
||||
everest_config_strategies: Optional[List[EverestConfigAdjustmentStrategy]] = None
|
||||
) -> None:
|
||||
self._core_config = core_config
|
||||
self._ocpp_config = ocpp_config
|
||||
self._probe_config = probe_config
|
||||
self._evse_security_config = evse_security_config
|
||||
self._persistent_store_config = persistent_store_config
|
||||
self._standalone_module = standalone_module
|
||||
if not self._standalone_module and self._probe_config:
|
||||
self._standalone_module = self._probe_config.module_id
|
||||
self._additional_everest_config_strategies = everest_config_strategies if everest_config_strategies else []
|
||||
self._everest_core = None
|
||||
self._ocpp_configuration = None
|
||||
|
||||
def setup_environment(self, tmp_path: Path):
|
||||
|
||||
temporary_paths = self._create_temporary_directory_structure(tmp_path)
|
||||
|
||||
configuration_strategies = self._create_everest_configuration_strategies(
|
||||
temporary_paths)
|
||||
|
||||
self._everest_core = EverestCore(self._core_config.everest_core_path,
|
||||
self._core_config.template_everest_config_path,
|
||||
everest_configuration_adjustment_strategies=configuration_strategies +
|
||||
self._additional_everest_config_strategies,
|
||||
standalone_module=self._standalone_module,
|
||||
tmp_path=tmp_path)
|
||||
|
||||
if self._ocpp_config:
|
||||
self._ocpp_configuration = self._setup_libocpp_configuration(
|
||||
temporary_paths=temporary_paths
|
||||
)
|
||||
|
||||
if self._evse_security_config:
|
||||
self._setup_evse_security_configuration(temporary_paths)
|
||||
|
||||
@property
|
||||
def everest_core(self) -> EverestCore:
|
||||
assert self._everest_core, "Everest Core not initialized; run 'setup_environment' first"
|
||||
return self._everest_core
|
||||
|
||||
@property
|
||||
def ocpp_config(self):
|
||||
return self._ocpp_configuration
|
||||
|
||||
def _create_temporary_directory_structure(self, tmp_path: Path) -> _EverestEnvironmentTemporaryPaths:
|
||||
ocpp_config_dir = tmp_path / "ocpp_config"
|
||||
ocpp_config_dir.mkdir(exist_ok=True)
|
||||
if self._ocpp_config and self._ocpp_config.ocpp_version == OCPPVersion.ocpp201:
|
||||
component_config_path_standardized = ocpp_config_dir / \
|
||||
"component_config" / "standardized"
|
||||
component_config_path_custom = ocpp_config_dir / "component_config" / "custom"
|
||||
component_config_path_standardized.mkdir(
|
||||
parents=True, exist_ok=True)
|
||||
component_config_path_custom.mkdir(parents=True, exist_ok=True)
|
||||
certs_dir = tmp_path / "certs"
|
||||
certs_dir.mkdir(exist_ok=True)
|
||||
ocpp_logs_dir = ocpp_config_dir / "logs"
|
||||
ocpp_logs_dir.mkdir(exist_ok=True)
|
||||
|
||||
persistent_store_dir = tmp_path / "persistent_storage"
|
||||
persistent_store_dir.mkdir(exist_ok=True)
|
||||
|
||||
logging.info(f"temp ocpp config files directory: {ocpp_config_dir}")
|
||||
|
||||
return self._EverestEnvironmentTemporaryPaths(
|
||||
ocpp_config_path=ocpp_config_dir / "component_config",
|
||||
ocpp_config_file=ocpp_config_dir / "config.json",
|
||||
ocpp_user_config_file=ocpp_config_dir / "user_config.json",
|
||||
ocpp_database_dir=ocpp_config_dir,
|
||||
certs_dir=certs_dir,
|
||||
ocpp_message_log_directory=ocpp_logs_dir,
|
||||
persistent_store_db_path=persistent_store_dir / "persistent_store.db"
|
||||
)
|
||||
|
||||
def _create_ocpp_module_configuration_strategy(self,
|
||||
temporary_paths: _EverestEnvironmentTemporaryPaths) -> OCPPModuleConfigurationStrategy:
|
||||
|
||||
if self._ocpp_config.ocpp_version == OCPPVersion.ocpp16:
|
||||
ocpp_paths = OCPPModulePaths16(
|
||||
ChargePointConfigPath=str(temporary_paths.ocpp_config_file),
|
||||
MessageLogPath=str(temporary_paths.ocpp_message_log_directory),
|
||||
UserConfigPath=str(temporary_paths.ocpp_user_config_file),
|
||||
DatabasePath=str(temporary_paths.ocpp_database_dir)
|
||||
)
|
||||
elif self._ocpp_config.ocpp_version == OCPPVersion.ocpp201 or self._ocpp_config.ocpp_version == OCPPVersion.ocpp21:
|
||||
ocpp_paths = OCPPModulePaths2X(
|
||||
DeviceModelConfigPath=str(temporary_paths.ocpp_config_path),
|
||||
MessageLogPath=str(temporary_paths.ocpp_message_log_directory),
|
||||
CoreDatabasePath=str(temporary_paths.ocpp_database_dir),
|
||||
DeviceModelDatabasePath=str(temporary_paths.ocpp_database_dir / "device_model_storage.db"),
|
||||
EverestDeviceModelDatabasePath=str(temporary_paths.ocpp_database_dir / "everest_device_model_storage.db")
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unknown ocpp version {self._ocpp_config.ocpp_version}")
|
||||
|
||||
occp_module_configuration_helper = OCPPModuleConfigurationStrategy(ocpp_paths=ocpp_paths,
|
||||
ocpp_module_id=self._ocpp_config.ocpp_module_id,
|
||||
ocpp_version=self._ocpp_config.ocpp_version)
|
||||
|
||||
return occp_module_configuration_helper
|
||||
|
||||
def _setup_libocpp_configuration(self, temporary_paths: _EverestEnvironmentTemporaryPaths):
|
||||
|
||||
liboccp_configuration_helper = LibOCPP16ConfigurationHelper(
|
||||
) if self._ocpp_config.ocpp_version == OCPPVersion.ocpp16 else LibOCPP2XConfigurationHelper()
|
||||
|
||||
if self._ocpp_config.template_ocpp_config:
|
||||
source_ocpp_config = self._ocpp_config.template_ocpp_config
|
||||
elif self._ocpp_config.ocpp_version == OCPPVersion.ocpp16:
|
||||
source_ocpp_config = self._determine_configured_charge_point_config_path_from_everest_config()
|
||||
elif self._ocpp_config.ocpp_version == OCPPVersion.ocpp201 or self._ocpp_config.ocpp_version == OCPPVersion.ocpp21:
|
||||
source_ocpp_config = self._ocpp_config.device_model_component_config_path
|
||||
|
||||
return liboccp_configuration_helper.generate_ocpp_config(
|
||||
central_system_port=self._ocpp_config.central_system_port,
|
||||
central_system_host=self._ocpp_config.central_system_host,
|
||||
source_ocpp_config_path=source_ocpp_config,
|
||||
target_ocpp_config_path=temporary_paths.ocpp_config_file
|
||||
if self._ocpp_config.ocpp_version == OCPPVersion.ocpp16
|
||||
else temporary_paths.ocpp_config_path,
|
||||
target_ocpp_user_config_file=temporary_paths.ocpp_user_config_file,
|
||||
configuration_strategies=self._ocpp_config.configuration_strategies
|
||||
)
|
||||
|
||||
def _create_everest_configuration_strategies(self, temporary_paths: _EverestEnvironmentTemporaryPaths):
|
||||
configuration_strategies = []
|
||||
if self._ocpp_config:
|
||||
configuration_strategies.append(
|
||||
self._create_ocpp_module_configuration_strategy(temporary_paths))
|
||||
if self._probe_config:
|
||||
configuration_strategies.append(
|
||||
ProbeModuleConfigurationStrategy(connections=self._probe_config.connections,
|
||||
module_id=self._probe_config.module_id))
|
||||
|
||||
if self._evse_security_config:
|
||||
configuration_strategies.append(
|
||||
EvseSecurityModuleConfigurationStrategy(module_id=self._evse_security_config.module_id,
|
||||
configuration=self._evse_security_config.module_configuration,
|
||||
source_certificates_directory=self._evse_security_config.source_certificate_directory,
|
||||
target_certificates_directory=temporary_paths.certs_dir
|
||||
if self._evse_security_config.use_temporary_certificates_folder
|
||||
else None
|
||||
))
|
||||
|
||||
if self._persistent_store_config and self._persistent_store_config.use_temporary_folder:
|
||||
configuration_strategies.append(
|
||||
PersistentStoreConfigurationStrategy(
|
||||
sqlite_db_file_path=temporary_paths.persistent_store_db_path)
|
||||
)
|
||||
|
||||
return configuration_strategies
|
||||
|
||||
def _determine_configured_charge_point_config_path_from_everest_config(self):
|
||||
|
||||
if self._ocpp_config.ocpp_version == OCPPVersion.ocpp16:
|
||||
everest_template_config = yaml.safe_load(
|
||||
self._core_config.template_everest_config_path.read_text())
|
||||
|
||||
charge_point_config_path = \
|
||||
everest_template_config["active_modules"][self._ocpp_config.ocpp_module_id]["config_module"][
|
||||
"ChargePointConfigPath"]
|
||||
|
||||
ocpp_dir = self._everest_core.prefix_path / "share/everest/modules/OCPP"
|
||||
else:
|
||||
raise ValueError(f"Could not determine ChargePointConfigPath for OCPP version {self._ocpp_config.ocpp_version}")
|
||||
ocpp_config_path = ocpp_dir / charge_point_config_path
|
||||
return ocpp_config_path
|
||||
|
||||
def _setup_evse_security_configuration(self, temporary_paths: _EverestEnvironmentTemporaryPaths):
|
||||
""" If configures, copies the source certificate trees"""
|
||||
if self._evse_security_config.source_certificate_directory:
|
||||
source_certs_directory = self._evse_security_config.source_certificate_directory
|
||||
else:
|
||||
source_certs_directory = self._everest_core.etc_path / 'certs'
|
||||
logging.warning(
|
||||
"No 'source_certificate_directory' configured in EverestEnvironmentEvseSecurityConfiguration. "
|
||||
f"Will use certificates from local installation {source_certs_directory}', which might lead to flaky tests.")
|
||||
shutil.copytree(source_certs_directory,
|
||||
temporary_paths.certs_dir, dirs_exist_ok=True)
|
||||
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from glob import glob
|
||||
import json
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Union, Callable
|
||||
|
||||
|
||||
class OCPPConfigAdjustmentStrategy(ABC):
|
||||
""" Strategy that manipulates a OCPP config when called. Cf. EverestConfigurationAdjustmentStrategy class
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def adjust_ocpp_configuration(self, config: dict) -> dict:
|
||||
""" Adjusts the provided configuration by making a (deep) copy and returning the adjusted configuration. """
|
||||
|
||||
|
||||
class OCPPConfigAdjustmentStrategyWrapper(OCPPConfigAdjustmentStrategy):
|
||||
""" Simple OCPPConfigAdjustmentStrategy from a callback function.
|
||||
"""
|
||||
|
||||
def __init__(self, callback: Callable[[dict], dict]):
|
||||
self._callback = callback
|
||||
|
||||
def adjust_ocpp_configuration(self, config: dict) -> dict:
|
||||
""" Adjusts the provided configuration by making a (deep) copy and returning the adjusted configuration. """
|
||||
config = deepcopy(config)
|
||||
return self._callback(config)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OCPP2XConfigVariableIdentifier:
|
||||
component_name: str
|
||||
variable_name: str
|
||||
variable_attribute_type: str = "Actual"
|
||||
|
||||
|
||||
class GenericOCPP16ConfigAdjustment(OCPPConfigAdjustmentStrategy):
|
||||
""" Generic OCPPConfigAdjustmentStrategy for OCPP 1.6 that allows simple variable value adjustments.
|
||||
|
||||
use e.g. via marker
|
||||
@pytest.mark.ocpp_config_adaptions(GenericOCPP16ConfigAdjustment([("Custom", "ExampleConfigurationKey", "test_value")]))
|
||||
"""
|
||||
|
||||
def __init__(self, adjustments: list[tuple[str, str, Any]]):
|
||||
self._adjustments = adjustments
|
||||
|
||||
def adjust_ocpp_configuration(self, config: dict):
|
||||
config = copy.deepcopy(config)
|
||||
for (category, variable, value) in self._adjustments:
|
||||
config.setdefault(category, {})[variable] = value
|
||||
return config
|
||||
|
||||
|
||||
class GenericOCPP2XConfigAdjustment(OCPPConfigAdjustmentStrategy):
|
||||
""" Generic OCPPConfigAdjustmentStrategy for OCPP 2.X that allows simple variable value adjustments.
|
||||
|
||||
use e.g. via marker
|
||||
@pytest.mark.ocpp_config_adaptions(GenericOCPP2XConfigAdjustment([(OCPP2XConfigVariableIdentifier("CustomCntrlr","TestVariableName", "Actual"), "test_value")]))
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, adjustments: list[tuple[OCPP2XConfigVariableIdentifier, Any]]):
|
||||
self._adjustments = adjustments
|
||||
|
||||
def adjust_ocpp_configuration(self, config: dict):
|
||||
config = copy.deepcopy(config)
|
||||
for identifier, value in self._adjustments:
|
||||
self._set_value_in_v2_config(config, identifier, value)
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def _get_value_from_v2_config(ocpp_config: dict, identifier: OCPP2XConfigVariableIdentifier):
|
||||
for (component, schema) in ocpp_config.items():
|
||||
if component == identifier.component_name:
|
||||
attributes = schema["properties"][identifier.variable_name]["attributes"]
|
||||
for attribute in attributes:
|
||||
if attribute["type"] == identifier.variable_attribute_type:
|
||||
return attribute["value"]
|
||||
|
||||
@staticmethod
|
||||
def _set_value_in_v2_config(ocpp_config: dict, identifier: OCPP2XConfigVariableIdentifier,
|
||||
value: Any):
|
||||
for (component, schema) in ocpp_config.items():
|
||||
if component == identifier.component_name:
|
||||
attributes = schema["properties"][identifier.variable_name]["attributes"]
|
||||
for attribute in attributes:
|
||||
if attribute["type"] == identifier.variable_attribute_type:
|
||||
attribute["value"] = value
|
||||
|
||||
|
||||
class _OCPP2XNetworkConnectionProfileAdjustment(OCPPConfigAdjustmentStrategy):
|
||||
""" Adjusts the OCPP 2.X Network Connection Profile by injecting the right host, port and chargepoint id.
|
||||
|
||||
This is utilized by the `LibOCPP2XConfigurationHelper`.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, central_system_port: int | str = None, central_system_host: str = None, security_profile: int = None):
|
||||
self._central_system_port = central_system_port
|
||||
self._central_system_host = central_system_host
|
||||
self._security_profile = security_profile
|
||||
|
||||
def adjust_ocpp_configuration(self, config: dict):
|
||||
config = deepcopy(config)
|
||||
network_connection_profiles = json.loads(GenericOCPP2XConfigAdjustment._get_value_from_v2_config(
|
||||
config, OCPP2XConfigVariableIdentifier("InternalCtrlr", "NetworkConnectionProfiles", "Actual")))
|
||||
for network_connection_profile in network_connection_profiles:
|
||||
selected_security_profile = network_connection_profile["connectionData"][
|
||||
"securityProfile"] if self._security_profile is None else self._security_profile
|
||||
selected_central_system_port = network_connection_profile["connectionData"][
|
||||
"ocppCsmsUrl"] if self._central_system_port is None else self._central_system_port
|
||||
selected_central_system_host = network_connection_profile["connectionData"][
|
||||
"ocppCsmsUrl"] if self._central_system_host is None else self._central_system_host
|
||||
protocol = "ws" if selected_security_profile == 1 else "wss"
|
||||
network_connection_profile["connectionData"][
|
||||
"ocppCsmsUrl"] = f"{protocol}://{selected_central_system_host}:{selected_central_system_port}"
|
||||
network_connection_profile["connectionData"][
|
||||
"securityProfile"] = selected_security_profile
|
||||
GenericOCPP2XConfigAdjustment._set_value_in_v2_config(config, OCPP2XConfigVariableIdentifier("InternalCtrlr", "NetworkConnectionProfiles",
|
||||
"Actual"), json.dumps(network_connection_profiles))
|
||||
return config
|
||||
|
||||
|
||||
class LibOCPPConfigurationHelperBase(ABC):
|
||||
""" Helper for parsing / adapting the LibOCPP configuration and dumping it a database file. """
|
||||
|
||||
def generate_ocpp_config(self,
|
||||
target_ocpp_config_path: Path,
|
||||
target_ocpp_user_config_file: Path,
|
||||
source_ocpp_config_path: Path,
|
||||
central_system_host: str,
|
||||
central_system_port: Union[str, int],
|
||||
configuration_strategies: list[OCPPConfigAdjustmentStrategy] | None = None):
|
||||
config = self._get_config(source_ocpp_config_path)
|
||||
|
||||
configuration_strategies = configuration_strategies if configuration_strategies else []
|
||||
|
||||
for v in [self._get_default_strategy(central_system_port, central_system_host)] + configuration_strategies:
|
||||
config = v.adjust_ocpp_configuration(config)
|
||||
|
||||
self._store_config(config, target_ocpp_config_path)
|
||||
target_ocpp_user_config_file.write_text("{}")
|
||||
|
||||
return config
|
||||
|
||||
@abstractmethod
|
||||
def _get_config(self, source_ocpp_config_path: Path):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _get_default_strategy(self, central_system_port: int | str,
|
||||
central_system_host: str) -> OCPPConfigAdjustmentStrategy:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _store_config(self, config, target_ocpp_config_file):
|
||||
pass
|
||||
|
||||
|
||||
class LibOCPP16ConfigurationHelper(LibOCPPConfigurationHelperBase):
|
||||
def _get_config(self, source_ocpp_config_path: Path):
|
||||
return json.loads(source_ocpp_config_path.read_text())
|
||||
|
||||
def _get_default_strategy(self, central_system_port, central_system_host):
|
||||
def adjust_ocpp_configuration(config: dict) -> dict:
|
||||
config = deepcopy(config)
|
||||
charge_point_id = config["Internal"]["ChargePointId"]
|
||||
config["Internal"][
|
||||
"CentralSystemURI"] = f"{central_system_host}:{central_system_port}/{charge_point_id}"
|
||||
return config
|
||||
|
||||
return OCPPConfigAdjustmentStrategyWrapper(adjust_ocpp_configuration)
|
||||
|
||||
def _store_config(self, config, target_ocpp_config_file):
|
||||
with target_ocpp_config_file.open("w") as f:
|
||||
json.dump(config, f)
|
||||
|
||||
|
||||
class LibOCPP2XConfigurationHelper(LibOCPPConfigurationHelperBase):
|
||||
|
||||
def _get_config(self, source_ocpp_config_path: Path):
|
||||
config = {}
|
||||
file_list_standardized = glob(
|
||||
str(source_ocpp_config_path / "standardized" / "*.json"), recursive=False)
|
||||
file_list_custom = glob(
|
||||
str(source_ocpp_config_path / "custom" / "*.json"), recursive=False)
|
||||
file_list = file_list_standardized + file_list_custom
|
||||
for file in file_list:
|
||||
# Get component from file name
|
||||
_, tail = os.path.split(file)
|
||||
component_name, _ = os.path.splitext(tail)
|
||||
# Store json in dict
|
||||
with open(file) as f:
|
||||
config[component_name] = json.load(f)
|
||||
return config
|
||||
|
||||
def _get_default_strategy(self, central_system_port: int | str,
|
||||
central_system_host: str) -> OCPPConfigAdjustmentStrategy:
|
||||
return _OCPP2XNetworkConnectionProfileAdjustment(central_system_port, central_system_host)
|
||||
|
||||
def _store_config(self, config, target_ocpp_config_path):
|
||||
# Just store all in the 'standardized' folder
|
||||
path = target_ocpp_config_path / "standardized"
|
||||
for key, value in config.items():
|
||||
file_name = path / (key + '.json')
|
||||
file_name.parent.mkdir(parents=True, exist_ok=True)
|
||||
with file_name.open("w+") as f:
|
||||
json.dump(value, f)
|
||||
@@ -0,0 +1,13 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Requirement:
|
||||
def __init__(self, module_id: str, implementation_id: str):
|
||||
self.module_id = module_id
|
||||
self.implementation_id = implementation_id
|
||||
|
||||
|
||||
class OCPPVersion(str, Enum):
|
||||
ocpp16 = "ocpp1.6"
|
||||
ocpp201 = "ocpp2.0.1"
|
||||
ocpp21 = "ocpp2.1"
|
||||
@@ -0,0 +1,156 @@
|
||||
import json
|
||||
import os
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt import __version__ as paho_mqtt_version
|
||||
|
||||
from everest.testing.core_utils.everest_core import EverestCore
|
||||
|
||||
from everest.testing.core_utils.controller.test_controller_interface import TestController
|
||||
|
||||
|
||||
class EverestTestController(TestController):
|
||||
|
||||
def __init__(self,
|
||||
everest_core: EverestCore
|
||||
):
|
||||
self._everest_core = everest_core
|
||||
self._mqtt_client = None
|
||||
|
||||
@property
|
||||
def _mqtt_external_prefix(self):
|
||||
return self._everest_core.mqtt_external_prefix
|
||||
|
||||
def start(self):
|
||||
self._initialize_external_mqtt_client()
|
||||
self._everest_core.start()
|
||||
self._initialize_nodered_sil()
|
||||
|
||||
def stop(self, *exc_details):
|
||||
self._everest_core.stop()
|
||||
self._destroy_mqtt_client()
|
||||
|
||||
def _initialize_external_mqtt_client(self):
|
||||
mqtt_server_uri = os.environ.get("MQTT_SERVER_ADDRESS", "127.0.0.1")
|
||||
mqtt_server_port = int(os.environ.get("MQTT_SERVER_PORT", "1883"))
|
||||
if paho_mqtt_version < '2.0':
|
||||
self._mqtt_client = mqtt.Client(self._everest_core.everest_uuid)
|
||||
else:
|
||||
self._mqtt_client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=self._everest_core.everest_uuid)
|
||||
self._mqtt_client.connect(mqtt_server_uri, mqtt_server_port)
|
||||
|
||||
def _initialize_nodered_sil(self):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/1/carsim/cmd/enable", "true")
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/2/carsim/cmd/enable", "true")
|
||||
|
||||
def plug_in(self, connector_id=1):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/execute_charging_session",
|
||||
"sleep 1;iec_wait_pwr_ready;sleep 1;draw_power_regulated 32,1;sleep 200;unplug")
|
||||
|
||||
def plug_in_ac_iso(self, connector_id=1, payment_type=""):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/execute_charging_session",
|
||||
f"sleep 1;iso_wait_slac_matched;iso_start_v2g_session AC {payment_type} 86400 0;iso_wait_pwr_ready;iso_draw_power_regulated 16,3;sleep 60;iso_stop_charging;iso_wait_v2g_session_stopped;unplug")
|
||||
|
||||
def plug_in_dc_iso(self, connector_id=1, payment_type=""):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/execute_charging_session",
|
||||
f"sleep 1;iso_wait_slac_matched;iso_start_v2g_session DC {payment_type} 86400 0;iso_wait_pwr_ready;iso_wait_for_stop 60;iso_wait_v2g_session_stopped;unplug"
|
||||
)
|
||||
|
||||
def plug_out(self, connector_id=1):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/modify_charging_session",
|
||||
"unplug")
|
||||
|
||||
def plug_out_iso(self, connector_id=1):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/modify_charging_session",
|
||||
"iso_stop_charging;iso_wait_v2g_session_stopped;unplug")
|
||||
|
||||
def pause_session(self, connector_id=1):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/modify_charging_session",
|
||||
"pause;sleep 36000"
|
||||
)
|
||||
|
||||
def resume_session(self, connector_id=1):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/modify_charging_session",
|
||||
"draw_power_regulated 16,3"
|
||||
)
|
||||
|
||||
def pause_iso_session(self, connector_id=1):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/modify_charging_session",
|
||||
"iso_pause_charging;iso_wait_for_resume"
|
||||
)
|
||||
|
||||
def resume_iso_session_ac(self, connector_id=1):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/modify_charging_session",
|
||||
"iso_start_bcb_toggle 3;iso_wait_pwm_is_running;iso_start_v2g_session AC 86400 0;iso_wait_pwr_ready;iso_draw_power_regulated 16,3;iso_wait_for_stop 60"
|
||||
)
|
||||
|
||||
def resume_iso_session_dc(self, connector_id=1):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/modify_charging_session",
|
||||
"iso_start_bcb_toggle 3;iso_wait_pwm_is_running;iso_start_v2g_session DC 86400 0;iso_wait_pwr_ready;iso_dc_power_on;iso_wait_for_stop 60"
|
||||
)
|
||||
|
||||
def swipe(self, token, connectors=None):
|
||||
connectors = connectors if connectors is not None else [1]
|
||||
provided_token = {
|
||||
"id_token": {
|
||||
"value": token,
|
||||
"type": "ISO14443"
|
||||
},
|
||||
"authorization_type": "RFID",
|
||||
"connectors": connectors
|
||||
}
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_api/dummy_token_provider/cmd/provide", json.dumps(provided_token))
|
||||
|
||||
def connect_websocket(self):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_api/ocpp/cmd/connect", "on")
|
||||
|
||||
def disconnect_websocket(self):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_api/ocpp/cmd/disconnect", "off")
|
||||
|
||||
def diode_fail(self, connector_id=1):
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/cmd/execute_charging_session",
|
||||
"sleep 1;iec_wait_pwr_ready;sleep 1;draw_power_regulated 32,3;sleep 5;diode_fail;sleep 36000;unplug")
|
||||
|
||||
def raise_error(self, error_string="MREC6UnderVoltage", connector_id=1):
|
||||
raise_error_payload = {
|
||||
"error_type": error_string,
|
||||
"raise": "true"
|
||||
}
|
||||
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/error",
|
||||
json.dumps(raise_error_payload))
|
||||
|
||||
def clear_error(self, error_string="MREC6UnderVoltage", connector_id=1):
|
||||
clear_error_payload = {
|
||||
"error_type": error_string,
|
||||
"raise": "false"
|
||||
}
|
||||
|
||||
self._mqtt_client.publish(
|
||||
f"{self._mqtt_external_prefix}everest_external/nodered/{connector_id}/carsim/error",
|
||||
json.dumps(clear_error_payload))
|
||||
|
||||
def publish(self, topic, payload):
|
||||
self._mqtt_client.publish(topic, payload)
|
||||
|
||||
def _destroy_mqtt_client(self):
|
||||
if self._mqtt_client:
|
||||
self._mqtt_client.disconnect()
|
||||
self._mqtt_client = None
|
||||
@@ -0,0 +1,121 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2020 - 2023 Pionix GmbH and Contributors to EVerest
|
||||
|
||||
class TestController():
|
||||
|
||||
"""This abstract class defines methods that are used within the test cases
|
||||
and should be implemented by you for your specific chargepoint and test
|
||||
environment. It includes definitions for the simulated behavior and events of a
|
||||
chargepoint and an electric vehicle.
|
||||
"""
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
This method starts the chargepoint. This includes
|
||||
the connection of the OCPP client to the CSMS.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
This method stops the chargepoint (similiar to power off). This includes
|
||||
the disconnection of the OCPP client from the CSMS.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def plug_in(self, connector_id):
|
||||
"""
|
||||
Plug in of an electric vehicle to the chargepoint.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def plug_in_ac_iso(self, payment_type, connector_id):
|
||||
"""
|
||||
Plug in of an electric vehicle to the chargepoint using AC ISO15118.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def plug_in_dc_iso(self, payment_type, connector_id):
|
||||
"""
|
||||
Plug in of an electric vehicle to the chargepoint using DC ISO15118.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def plug_out_iso(self, connector_id):
|
||||
"""
|
||||
Plug out of an electric vehicle properly ending the ISO15118 session.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def pause_session(self, connector_id):
|
||||
"""
|
||||
Pause an ongoing charging session.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def resume_session(self, connector_id):
|
||||
"""
|
||||
Resume a paused charging session.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def pause_iso_session(self, connector_id):
|
||||
"""
|
||||
Pause an ongoing ISO15118 session initiated by the EV.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def resume_iso_session_ac(self, connector_id):
|
||||
"""
|
||||
Resume a paused ISO15118 session initiated by the EV.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def resume_iso_session_dc(self, connector_id):
|
||||
"""
|
||||
Resume a paused ISO15118 session initiated by the EV.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def plug_out(self):
|
||||
"""
|
||||
Plug out of an electric vehicle from the chargepoint.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def swipe(self, token):
|
||||
"""
|
||||
Swipe the given RFID card at the RFID reader of the chargepoint.
|
||||
"""
|
||||
|
||||
|
||||
def connect_websocket(self):
|
||||
"""
|
||||
Connect the OCPP client. This method is only used after a disconnect_websocket call
|
||||
in the tests.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def disconnect_websocket(self):
|
||||
"""
|
||||
Disconnects the OCPP client from the CSMS. The chargepoint still is powered up.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def didoe_fail(self):
|
||||
"""
|
||||
Produces an RCD Error.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def raise_error(self, error_string, connector_id):
|
||||
"""
|
||||
Produces an error (default MREC6UnderVoltage).
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def clear_error(self, error_string, connector_id):
|
||||
"""
|
||||
Clears an error (default MREC6UnderVoltage).
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,253 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from threading import Thread
|
||||
import threading
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import List, Optional, Union, Dict
|
||||
import uuid
|
||||
import yaml
|
||||
import selectors
|
||||
from signal import SIGINT
|
||||
|
||||
from everest.framework import RuntimeSession
|
||||
from everest.testing.core_utils.common import Requirement
|
||||
from ._configuration.everest_configuration_strategies.everest_configuration_strategy import \
|
||||
EverestConfigAdjustmentStrategy
|
||||
from ._configuration.everest_configuration_strategies.mqtt_configuration_strategy import \
|
||||
EverestMqttConfigurationAdjustmentStrategy
|
||||
from ._configuration.everest_configuration_strategies.probe_module_configuration_strategy import \
|
||||
ProbeModuleConfigurationStrategy
|
||||
|
||||
STARTUP_TIMEOUT = 30
|
||||
|
||||
Connections = dict[str, List[Requirement]]
|
||||
|
||||
|
||||
class StatusFifoListener:
|
||||
def __init__(self, status_fifo_path: Path):
|
||||
if not status_fifo_path.exists():
|
||||
os.mkfifo(status_fifo_path)
|
||||
|
||||
# note: open doesn't support non-blocking, so we use os.open to get the fd
|
||||
fd = os.open(status_fifo_path, flags=(os.O_RDONLY | os.O_NONBLOCK))
|
||||
self._file_obj = open(fd)
|
||||
|
||||
selector = selectors.DefaultSelector()
|
||||
selector.register(self._file_obj, selectors.EVENT_READ)
|
||||
self._selector = selector
|
||||
|
||||
def wait_for_status(self, timeout: float, match_status: list[str]) -> Optional[list[str]]:
|
||||
if match_status is None:
|
||||
match_status = []
|
||||
|
||||
end_time = time.time() + timeout
|
||||
|
||||
while True:
|
||||
for _key, _mask in self._selector.select(timeout):
|
||||
data = self._file_obj.read()
|
||||
if len(data) == 0:
|
||||
return None
|
||||
|
||||
# plural!
|
||||
received_status = data.splitlines()
|
||||
|
||||
if len(match_status) == 0:
|
||||
# we're not trying to match any messages
|
||||
return received_status
|
||||
|
||||
# return the filtered matched messages
|
||||
matched_status = [status for status in match_status if status in received_status]
|
||||
if len(matched_status) > 0:
|
||||
return matched_status
|
||||
|
||||
timeout = end_time - time.time()
|
||||
|
||||
if timeout < 0:
|
||||
return []
|
||||
|
||||
|
||||
class EverestCore:
|
||||
"""This class can be used to configure, start and stop a full build of EVerest
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
prefix_path: Path,
|
||||
config_path: Path = None,
|
||||
standalone_module: Optional[Union[str, List[str]]] = None,
|
||||
everest_configuration_adjustment_strategies: Optional[
|
||||
List[EverestConfigAdjustmentStrategy]] = None,
|
||||
tmp_path: Optional[Path] = None) -> None:
|
||||
"""Initialize EVerest using everest_core_path and everest_config_path
|
||||
|
||||
Args:
|
||||
everest_prefix (Path): location of installed everest distribution".
|
||||
standalone_module (str): Standalone module parameter provided to EVerest manager app (can be overwritten in startup)
|
||||
"""
|
||||
|
||||
self.process = None
|
||||
self.everest_uuid = uuid.uuid4().hex
|
||||
|
||||
if not tmp_path:
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix=self.everest_uuid))
|
||||
temp_everest_config_file = tempfile.NamedTemporaryFile(
|
||||
delete=False, mode="w+", suffix=".yaml", dir=temp_dir)
|
||||
self.everest_config_path = Path(temp_everest_config_file.name)
|
||||
self.everest_core_user_config_path = Path(
|
||||
temp_everest_config_file.name).parent / 'user-config'
|
||||
self.everest_core_user_config_path.mkdir(parents=True, exist_ok=True)
|
||||
self._status_fifo_path = temp_dir / "status.fifo"
|
||||
else:
|
||||
config_dir = tmp_path / "everest_config"
|
||||
config_dir.mkdir()
|
||||
self.everest_core_user_config_path = config_dir / "user-config"
|
||||
self.everest_core_user_config_path.mkdir()
|
||||
self.everest_config_path = config_dir / "everest_config.yaml"
|
||||
self._status_fifo_path = tmp_path / "status.fifo"
|
||||
|
||||
self.prefix_path = prefix_path
|
||||
self.etc_path = Path('/etc/everest') if prefix_path == '/usr' else prefix_path / 'etc/everest'
|
||||
|
||||
if config_path is None:
|
||||
config_path = self.etc_path / 'config-sil.yaml'
|
||||
|
||||
self.mqtt_external_prefix = f"external_{self.everest_uuid}"
|
||||
|
||||
self._write_temporary_config(config_path, everest_configuration_adjustment_strategies)
|
||||
|
||||
logging.info(f"everest uuid: {self.everest_uuid}")
|
||||
logging.info(f"temp everest config: {self.everest_config_path} based on {config_path}")
|
||||
|
||||
self.test_control_modules = None
|
||||
|
||||
self.log_reader_thread: Thread = None
|
||||
self.everest_running = False
|
||||
self.all_modules_started_event = threading.Event()
|
||||
|
||||
self._standalone_module = standalone_module
|
||||
|
||||
@property
|
||||
def everest_config(self) -> Dict:
|
||||
with self.everest_config_path.open("r") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def _write_temporary_config(self, template_config_path: Path, everest_configuration_adjustment_strategies: Optional[
|
||||
List[EverestConfigAdjustmentStrategy]]):
|
||||
everest_configuration_adjustment_strategies = everest_configuration_adjustment_strategies if everest_configuration_adjustment_strategies else []
|
||||
everest_configuration_adjustment_strategies.append(
|
||||
EverestMqttConfigurationAdjustmentStrategy(everest_uuid=self.everest_uuid,
|
||||
mqtt_external_prefix=self.mqtt_external_prefix))
|
||||
everest_config = yaml.safe_load(template_config_path.read_text())
|
||||
for strategy in everest_configuration_adjustment_strategies:
|
||||
everest_config = strategy.adjust_everest_configuration(everest_config)
|
||||
with self.everest_config_path.open("w") as f:
|
||||
yaml.dump(everest_config, f)
|
||||
|
||||
def start(self, standalone_module: Optional[Union[str, List[str]]] = None, test_connections: Connections = None):
|
||||
"""Starts EVerest in a subprocess
|
||||
|
||||
Args:
|
||||
standalone_module (str, optional): If set, a submodule can be started separately. EVerest will then wait for the submodule to be started.
|
||||
Defaults to None.
|
||||
"""
|
||||
|
||||
standalone_module = standalone_module if standalone_module is not None else self._standalone_module
|
||||
|
||||
manager_path = self.prefix_path / 'bin/manager'
|
||||
|
||||
logging.info(f'config: {self.everest_config_path}')
|
||||
|
||||
# FIXME (aw): clean up passing of modules_to_test
|
||||
self.test_connections = test_connections if test_connections != None else {}
|
||||
self._create_testing_user_config()
|
||||
|
||||
self.status_listener = StatusFifoListener(self._status_fifo_path)
|
||||
logging.info(self._status_fifo_path)
|
||||
|
||||
args = [str(manager_path.resolve()), '--config', str(self.everest_config_path),
|
||||
'--status-fifo', str(self._status_fifo_path), '--prefix', str(self.prefix_path.resolve())]
|
||||
|
||||
if standalone_module:
|
||||
logging.info(f"Standalone module {standalone_module} was specified")
|
||||
if not isinstance(standalone_module, list):
|
||||
standalone_module = [standalone_module]
|
||||
for s in standalone_module:
|
||||
args.extend(['--standalone', s])
|
||||
|
||||
logging.info(" ".join(args))
|
||||
|
||||
logging.info('Starting EVerest...')
|
||||
logging.info(' '.join(args))
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
args, cwd=self.prefix_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
self.log_reader_thread = Thread(target=self.read_everest_log)
|
||||
self.log_reader_thread.start()
|
||||
|
||||
expected_status = 'ALL_MODULES_STARTED' if standalone_module == None else 'WAITING_FOR_STANDALONE_MODULES'
|
||||
|
||||
status = self.status_listener.wait_for_status(STARTUP_TIMEOUT, [expected_status])
|
||||
if status == None or len(status) == 0:
|
||||
self.read_everest_log()
|
||||
raise TimeoutError("Timeout while waiting for EVerest to start")
|
||||
|
||||
logging.info("EVerest has started")
|
||||
if expected_status == 'ALL_MODULES_STARTED':
|
||||
self.all_modules_started_event.set()
|
||||
|
||||
def read_everest_log(self):
|
||||
while self.process.poll() == None:
|
||||
stderr_raw = self.process.stderr.readline()
|
||||
stderr_formatted = stderr_raw.strip().decode(errors="ignore")
|
||||
logging.debug(f' {stderr_formatted}')
|
||||
|
||||
if self.process.returncode == 0:
|
||||
logging.info("EVerest stopped with return code 0")
|
||||
elif self.process.returncode < 0:
|
||||
logging.info(f"EVerest stopped by signal {signal.Signals(-self.process.returncode).name}")
|
||||
else:
|
||||
logging.warning(f"EVerest stopped with return code: {self.process.returncode}")
|
||||
|
||||
logging.debug("EVerest output stopped")
|
||||
|
||||
def stop(self):
|
||||
"""Stops execution of EVerest by signaling SIGINT
|
||||
"""
|
||||
logging.debug("CONTROLLER stop() function called...")
|
||||
if self.process:
|
||||
# NOTE (aw): we could also call process.kill()
|
||||
self.process.send_signal(SIGINT)
|
||||
self.process.wait()
|
||||
|
||||
if self.log_reader_thread:
|
||||
self.log_reader_thread.join()
|
||||
|
||||
def _create_testing_user_config(self):
|
||||
"""Creates a user-config file to include the PyTestControlModule in the current SIL simulation.
|
||||
If a user-config already exists, it will be re-named
|
||||
"""
|
||||
|
||||
if len(self.test_connections) == 0:
|
||||
# nothing to do here
|
||||
return
|
||||
|
||||
file = self.everest_core_user_config_path / self.everest_config_path.name
|
||||
logging.info(f"temp everest user-config: {file.resolve()}")
|
||||
|
||||
# FIXME (aw): we need some agreement, if the module id of the probe module should be fixed or not
|
||||
logging.info(f'Adding test control module(s) to user-config: {self.test_control_modules}')
|
||||
user_config = {}
|
||||
user_config = ProbeModuleConfigurationStrategy(connections=self.test_connections).adjust_everest_configuration(user_config)
|
||||
|
||||
file.write_text(yaml.dump(user_config))
|
||||
|
||||
def get_runtime_session(self):
|
||||
return RuntimeSession(str(self.prefix_path), str(self.everest_config_path))
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2020 - 2023 Pionix GmbH and Contributors to EVerest
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt import __version__ as paho_mqtt_version
|
||||
|
||||
from ._configuration.everest_configuration_strategies.everest_configuration_strategy import \
|
||||
EverestConfigAdjustmentStrategy
|
||||
from ._configuration.everest_environment_setup import \
|
||||
EverestEnvironmentProbeModuleConfiguration, \
|
||||
EverestTestEnvironmentSetup, EverestEnvironmentOCPPConfiguration, EverestEnvironmentCoreConfiguration, \
|
||||
EverestEnvironmentEvseSecurityConfiguration, EverestEnvironmentPersistentStoreConfiguration
|
||||
from everest.testing.core_utils.controller.everest_test_controller import EverestTestController
|
||||
from everest.testing.core_utils.everest_core import EverestCore
|
||||
from everest.testing.core_utils.network_isolation import (
|
||||
NetworkIsolationStrategy,
|
||||
WORKER_INTERFACE_ENV,
|
||||
WORKER_PROXY_INTERFACE_ENV,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def probe_module_config(request) -> Optional[EverestEnvironmentProbeModuleConfiguration]:
|
||||
marker = request.node.get_closest_marker("probe_module")
|
||||
if marker:
|
||||
return EverestEnvironmentProbeModuleConfiguration(
|
||||
**marker.kwargs
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def core_config(request) -> EverestEnvironmentCoreConfiguration:
|
||||
everest_prefix = Path(request.config.getoption("--everest-prefix"))
|
||||
|
||||
marker = request.node.get_closest_marker("everest_core_config")
|
||||
if marker is None:
|
||||
everest_config_path = None # config auto-detected by everest core
|
||||
else:
|
||||
path = Path('/etc/everest') if everest_prefix == '/usr' else everest_prefix / 'etc/everest'
|
||||
everest_config_path = path / marker.args[0]
|
||||
|
||||
return EverestEnvironmentCoreConfiguration(
|
||||
everest_core_path=everest_prefix,
|
||||
template_everest_config_path=everest_config_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ocpp_config(request) -> Optional[EverestEnvironmentOCPPConfiguration]:
|
||||
return None
|
||||
|
||||
@pytest.fixture
|
||||
def evse_security_config(request) -> Optional[EverestEnvironmentEvseSecurityConfiguration]:
|
||||
source_certs_dir_marker = request.node.get_closest_marker("source_certs_dir")
|
||||
if source_certs_dir_marker:
|
||||
return EverestEnvironmentEvseSecurityConfiguration(source_certificate_directory=Path(source_certs_dir_marker.args[0]))
|
||||
return None
|
||||
|
||||
@pytest.fixture
|
||||
def persistent_store_config(request) -> Optional[EverestEnvironmentPersistentStoreConfiguration]:
|
||||
persistent_store_marker = request.node.get_closest_marker("use_temporary_persistent_store")
|
||||
if persistent_store_marker:
|
||||
return EverestEnvironmentPersistentStoreConfiguration(use_temporary_folder=True)
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def everest_config_strategies(request) -> list[EverestConfigAdjustmentStrategy]:
|
||||
additional_configuration_strategies = []
|
||||
additional_configuration_strategies_marker = request.node.get_closest_marker('everest_config_adaptions')
|
||||
if additional_configuration_strategies_marker:
|
||||
for v in additional_configuration_strategies_marker.args:
|
||||
assert isinstance(v, EverestConfigAdjustmentStrategy), "Arguments to 'everest_config_adaptions' must all be instances of EverestConfigAdjustmentStrategy"
|
||||
additional_configuration_strategies.append(v)
|
||||
|
||||
# Auto-inject NetworkIsolationStrategy when a worker interface is assigned.
|
||||
# The env vars are set by the NetworkIsolationPlugin on xdist worker nodes.
|
||||
interface = os.environ.get(WORKER_INTERFACE_ENV)
|
||||
if interface:
|
||||
proxy_interface = os.environ.get(WORKER_PROXY_INTERFACE_ENV)
|
||||
additional_configuration_strategies.append(NetworkIsolationStrategy(interface, proxy_interface))
|
||||
|
||||
return additional_configuration_strategies
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def everest_environment(request,
|
||||
tmp_path,
|
||||
core_config: EverestEnvironmentCoreConfiguration,
|
||||
ocpp_config: Optional[EverestEnvironmentOCPPConfiguration],
|
||||
probe_module_config: Optional[EverestEnvironmentProbeModuleConfiguration],
|
||||
evse_security_config: Optional[EverestEnvironmentEvseSecurityConfiguration],
|
||||
persistent_store_config: Optional[EverestEnvironmentPersistentStoreConfiguration],
|
||||
everest_config_strategies
|
||||
):
|
||||
standalone_module_marker = request.node.get_closest_marker('standalone_module')
|
||||
|
||||
environment_setup = EverestTestEnvironmentSetup(
|
||||
core_config=core_config,
|
||||
ocpp_config=ocpp_config,
|
||||
probe_config=probe_module_config,
|
||||
evse_security_config=evse_security_config,
|
||||
persistent_store_config=persistent_store_config,
|
||||
standalone_module=list(standalone_module_marker.args) if standalone_module_marker else None,
|
||||
everest_config_strategies=everest_config_strategies
|
||||
)
|
||||
|
||||
environment_setup.setup_environment(tmp_path=tmp_path)
|
||||
|
||||
yield environment_setup
|
||||
|
||||
@pytest.fixture
|
||||
def everest_core(request,
|
||||
everest_environment
|
||||
)-> EverestCore:
|
||||
"""Fixture that can be used to start and stop EVerest"""
|
||||
|
||||
yield everest_environment.everest_core
|
||||
|
||||
# FIXME (aw): proper life time management, shouldn't the fixure start and stop?
|
||||
everest_environment.everest_core.stop()
|
||||
|
||||
@pytest.fixture
|
||||
def ocpp_configuration(everest_environment):
|
||||
yield everest_environment.ocpp_config
|
||||
|
||||
@pytest.fixture
|
||||
def test_controller(request, tmp_path, everest_core) -> EverestTestController:
|
||||
"""Fixture that references the test_controller that can be used for
|
||||
control events for the test cases.
|
||||
"""
|
||||
|
||||
test_controller = EverestTestController(everest_core=everest_core)
|
||||
|
||||
yield test_controller
|
||||
|
||||
# FIXME (aw): proper life time management, shouldn't the fixure start and stop?
|
||||
test_controller.stop()
|
||||
|
||||
@pytest.fixture
|
||||
def connected_mqtt_client(everest_core: EverestCore) -> mqtt.Client:
|
||||
mqtt_server_uri = os.environ.get("MQTT_SERVER_ADDRESS", "127.0.0.1")
|
||||
mqtt_server_port = int(os.environ.get("MQTT_SERVER_PORT", "1883"))
|
||||
if paho_mqtt_version < '2.0':
|
||||
client = mqtt.Client(everest_core.everest_uuid)
|
||||
else:
|
||||
client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION1, client_id=everest_core.everest_uuid)
|
||||
client.connect(mqtt_server_uri, mqtt_server_port)
|
||||
client.loop_start()
|
||||
|
||||
yield client
|
||||
|
||||
client.loop_stop()
|
||||
@@ -0,0 +1,20 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
from .strategy import NetworkIsolationStrategy
|
||||
from .plugin import (
|
||||
NetworkIsolationPlugin,
|
||||
get_worker_interface,
|
||||
VETH_PREFIX,
|
||||
WORKER_INTERFACE_ENV,
|
||||
WORKER_PROXY_INTERFACE_ENV,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"NetworkIsolationStrategy",
|
||||
"NetworkIsolationPlugin",
|
||||
"VETH_PREFIX",
|
||||
"get_worker_interface",
|
||||
"WORKER_INTERFACE_ENV",
|
||||
"WORKER_PROXY_INTERFACE_ENV",
|
||||
]
|
||||
@@ -0,0 +1,196 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
"""
|
||||
Pytest plugin for network-isolated parallel ISO 15118 test execution.
|
||||
|
||||
This plugin integrates with pytest-xdist to:
|
||||
1. Detect pre-existing veth pairs (created externally via setup-network-isolation.sh)
|
||||
2. Assign a unique interface to each xdist worker via workerinput
|
||||
3. Strip @pytest.mark.xdist_group(name="ISO15118") markers so those tests
|
||||
can be distributed freely across workers
|
||||
4. Automatically inject a NetworkIsolationStrategy via the everest_config_strategies
|
||||
fixture override in conftest.py
|
||||
|
||||
When veth pairs are NOT available, the plugin is a no-op and the xdist_group
|
||||
markers remain — tests fall back to sequential execution within their group.
|
||||
|
||||
The plugin never creates or destroys network interfaces. That is done externally
|
||||
via `setup-network-isolation.sh` (which requires sudo / CAP_NET_ADMIN).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
# Naming convention for veth pairs: ev_test0/ev_test0_peer, ev_test1/ev_test1_peer, ...
|
||||
VETH_PREFIX = "ev_test"
|
||||
|
||||
# Environment variables used to pass the assigned interfaces from controller to workers
|
||||
WORKER_INTERFACE_ENV = "EVEREST_TEST_NETWORK_INTERFACE"
|
||||
WORKER_PROXY_INTERFACE_ENV = "EVEREST_TEST_PROXY_NETWORK_INTERFACE"
|
||||
|
||||
# The xdist_group name used by ISO 15118 tests
|
||||
ISO15118_XDIST_GROUP = "ISO15118"
|
||||
|
||||
|
||||
def interface_exists(name: str) -> bool:
|
||||
"""Check if a network interface exists."""
|
||||
result = subprocess.run(
|
||||
["ip", "link", "show", name],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_worker_index(worker_id: str) -> Optional[int]:
|
||||
"""Extract numeric index from xdist worker_id (e.g., 'gw0' -> 0).
|
||||
|
||||
Returns None if not running under xdist (worker_id == 'master').
|
||||
"""
|
||||
if worker_id == "master":
|
||||
return None
|
||||
# worker_id format: "gw0", "gw1", etc.
|
||||
return int(worker_id.replace("gw", ""))
|
||||
|
||||
|
||||
def get_worker_interface() -> Optional[str]:
|
||||
"""Get the network interface assigned to the current xdist worker.
|
||||
|
||||
Returns None if network isolation is not active.
|
||||
"""
|
||||
return os.environ.get(WORKER_INTERFACE_ENV)
|
||||
|
||||
|
||||
class NetworkIsolationPlugin:
|
||||
"""Pytest plugin that detects pre-existing veth pairs and enables parallel
|
||||
ISO 15118 test execution.
|
||||
|
||||
Behavior:
|
||||
--network-isolation passed AND veth pairs exist:
|
||||
-> Adopts interfaces, assigns one per worker, strips xdist_group markers
|
||||
--network-isolation passed but NO veth pairs:
|
||||
-> Logs a warning, xdist_group markers stay (sequential fallback)
|
||||
--network-isolation NOT passed:
|
||||
-> Plugin is not registered, everything works as before
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._num_workers: int = 0
|
||||
self._active = False # True only if interfaces were found
|
||||
|
||||
@staticmethod
|
||||
def register(config):
|
||||
"""Register this plugin with pytest if --network-isolation is passed."""
|
||||
if config.getoption("--network-isolation", default=False):
|
||||
plugin = NetworkIsolationPlugin()
|
||||
config.pluginmanager.register(plugin, "network_isolation")
|
||||
|
||||
def pytest_configure_node(self, node):
|
||||
"""Called on the controller for each xdist worker node.
|
||||
|
||||
Passes the assigned interface name via workerinput.
|
||||
"""
|
||||
if not self._active:
|
||||
return
|
||||
worker_id = node.workerinput["workerid"]
|
||||
idx = get_worker_index(worker_id)
|
||||
if idx is not None and idx < self._num_workers:
|
||||
interface = f"{VETH_PREFIX}{idx}"
|
||||
node.workerinput["network_interface"] = interface
|
||||
node.workerinput["proxy_interface"] = f"{interface}_peer"
|
||||
|
||||
@staticmethod
|
||||
def pytest_configure(config):
|
||||
"""On worker nodes, read the assigned interface and set the env var.
|
||||
|
||||
xdist workers have `config.workerinput` populated by the controller's
|
||||
`pytest_configure_node` hook.
|
||||
"""
|
||||
workerinput = getattr(config, "workerinput", None)
|
||||
if workerinput and "network_interface" in workerinput:
|
||||
os.environ[WORKER_INTERFACE_ENV] = workerinput["network_interface"]
|
||||
if workerinput and "proxy_interface" in workerinput:
|
||||
os.environ[WORKER_PROXY_INTERFACE_ENV] = workerinput["proxy_interface"]
|
||||
|
||||
def pytest_sessionstart(self, session):
|
||||
"""Detect and adopt pre-existing veth pairs at session start."""
|
||||
# Determine worker count
|
||||
num_workers = session.config.getoption("numprocesses", default=None)
|
||||
if num_workers is None or num_workers == 0:
|
||||
num_workers = os.cpu_count() or 4
|
||||
if isinstance(num_workers, str):
|
||||
num_workers = os.cpu_count() or 4 if num_workers == "auto" else int(num_workers)
|
||||
|
||||
# Check if interfaces exist (created by setup-network-isolation.sh)
|
||||
first_iface = f"{VETH_PREFIX}0"
|
||||
if not interface_exists(first_iface):
|
||||
logging.warning(
|
||||
"--network-isolation was passed but no veth interfaces found "
|
||||
"(expected %s). ISO 15118 tests will fall back to sequential "
|
||||
"execution via xdist_group. Run 'sudo ./setup-network-isolation.sh "
|
||||
"setup %d' first to enable parallel execution.",
|
||||
first_iface,
|
||||
num_workers,
|
||||
)
|
||||
return
|
||||
|
||||
self._num_workers = num_workers
|
||||
self._active = True
|
||||
|
||||
@staticmethod
|
||||
def _is_iso15118_xdist_marker(marker) -> bool:
|
||||
"""Check if a marker is @pytest.mark.xdist_group(name="ISO15118")."""
|
||||
return (
|
||||
marker.name == "xdist_group"
|
||||
and (
|
||||
marker.kwargs.get("name") == ISO15118_XDIST_GROUP
|
||||
or (marker.args and marker.args[0] == ISO15118_XDIST_GROUP)
|
||||
)
|
||||
)
|
||||
|
||||
def _strip_marker_from_node(self, node) -> bool:
|
||||
"""Remove ISO15118 xdist_group marker from a single node. Returns True if removed."""
|
||||
original_len = len(node.own_markers)
|
||||
node.own_markers = [
|
||||
m for m in node.own_markers
|
||||
if not self._is_iso15118_xdist_marker(m)
|
||||
]
|
||||
return len(node.own_markers) < original_len
|
||||
|
||||
def pytest_collection_modifyitems(self, config, items):
|
||||
"""Remove xdist_group('ISO15118') markers when network isolation is active.
|
||||
|
||||
This allows ISO 15118 tests to be distributed freely across workers
|
||||
instead of being forced into a single sequential group.
|
||||
|
||||
Markers can live on test functions, classes, or modules, so we strip
|
||||
from the item and all its parent nodes.
|
||||
"""
|
||||
if not self._active:
|
||||
return
|
||||
|
||||
processed_parents = set()
|
||||
|
||||
for item in items:
|
||||
# Check if this item inherits an ISO15118 xdist_group marker
|
||||
has_iso_group = any(
|
||||
self._is_iso15118_xdist_marker(m)
|
||||
for m in item.iter_markers("xdist_group")
|
||||
)
|
||||
if not has_iso_group:
|
||||
continue
|
||||
|
||||
# Strip from the item itself
|
||||
self._strip_marker_from_node(item)
|
||||
|
||||
# Strip from parent nodes (class, module) — but only once per parent
|
||||
parent = item.parent
|
||||
while parent is not None:
|
||||
parent_id = id(parent)
|
||||
if parent_id not in processed_parents:
|
||||
processed_parents.add(parent_id)
|
||||
self._strip_marker_from_node(parent)
|
||||
parent = parent.parent
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
"""
|
||||
EverestConfigAdjustmentStrategy that rewrites the `device` field in ISO 15118
|
||||
modules to use a specific network interface, enabling parallel test execution.
|
||||
|
||||
Usage:
|
||||
@pytest.mark.everest_config_adaptions(NetworkIsolationStrategy("ev_test0", "ev_test0_peer"))
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Dict, Optional
|
||||
|
||||
from everest.testing.core_utils import EverestConfigAdjustmentStrategy
|
||||
|
||||
# EV-facing modules: listen on the EV-side of the veth pair
|
||||
_EV_SIDE_MODULES = frozenset({"PyEvJosev", "IsoMux"})
|
||||
|
||||
# EVSE proxy-side modules: listen on the EVSE/proxy-side of the veth pair
|
||||
_PROXY_SIDE_MODULES = frozenset({"EvseV2G", "Evse15118D20"})
|
||||
|
||||
# All ISO 15118 module types handled by this strategy
|
||||
ISO15118_MODULE_TYPES = _EV_SIDE_MODULES | _PROXY_SIDE_MODULES
|
||||
|
||||
|
||||
class NetworkIsolationStrategy(EverestConfigAdjustmentStrategy):
|
||||
"""Rewrites `device` in ISO 15118 modules to use dedicated network interfaces.
|
||||
|
||||
This enables multiple ISO 15118 test sessions to run in parallel by assigning
|
||||
each test to a separate virtual ethernet (veth) interface pair, avoiding port
|
||||
and multicast conflicts.
|
||||
|
||||
When IsoMux is present:
|
||||
- IsoMux and PyEvJosev use `interface_name` as `device` (EV-facing side).
|
||||
- EvseV2G and Evse15118D20 use `proxy_interface_name` as `device`.
|
||||
- IsoMux `proxy_device` is set to `proxy_interface_name` so it connects
|
||||
to the ISO-2/ISO-20 instances via link-local instead of loopback.
|
||||
|
||||
When IsoMux is absent:
|
||||
- All ISO 15118 modules use `interface_name` as `device`.
|
||||
|
||||
Args:
|
||||
interface_name: The EV-facing network interface (e.g., "ev_test0").
|
||||
proxy_interface_name: The EVSE proxy-facing interface (e.g., "ev_test0_peer").
|
||||
Used only when IsoMux is present in the config.
|
||||
"""
|
||||
|
||||
def __init__(self, interface_name: str, proxy_interface_name: Optional[str] = None):
|
||||
self._interface_name = interface_name
|
||||
self._proxy_interface_name = proxy_interface_name
|
||||
|
||||
def adjust_everest_configuration(self, everest_config: Dict) -> Dict:
|
||||
adjusted_config = deepcopy(everest_config)
|
||||
|
||||
active_modules = adjusted_config.get("active_modules", {})
|
||||
|
||||
has_isomux = any(
|
||||
module_def.get("module") == "IsoMux"
|
||||
for module_def in active_modules.values()
|
||||
)
|
||||
|
||||
for module_def in active_modules.values():
|
||||
module_type = module_def.get("module", "")
|
||||
if module_type not in ISO15118_MODULE_TYPES:
|
||||
continue
|
||||
|
||||
config_module = module_def.get("config_module", {})
|
||||
|
||||
if module_type in _EV_SIDE_MODULES:
|
||||
if "device" in config_module:
|
||||
config_module["device"] = self._interface_name
|
||||
if module_type == "IsoMux" and self._proxy_interface_name:
|
||||
config_module["proxy_device"] = self._proxy_interface_name
|
||||
else: # _PROXY_SIDE_MODULES
|
||||
if "device" in config_module:
|
||||
config_module["device"] = (
|
||||
self._proxy_interface_name
|
||||
if has_isomux and self._proxy_interface_name
|
||||
else self._interface_name
|
||||
)
|
||||
|
||||
return adjusted_config
|
||||
@@ -0,0 +1,178 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from queue import Queue
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from everest.framework import Module, RuntimeSession
|
||||
from everest.framework import error
|
||||
|
||||
class ProbeModule:
|
||||
"""
|
||||
Probe module tool for integration testing, which is a thin abstraction over the C++ bindings from everestpy
|
||||
You need to declare the requirements for the probe module with the fixtures starting EVerest ("test_connections" argument),
|
||||
but you do not need to specify the interfaces provided by the probe - simply specify the implementation ID when registering cmd handlers and publishing vars.
|
||||
"""
|
||||
|
||||
def __init__(self, session: RuntimeSession, module_id="probe"):
|
||||
"""
|
||||
Construct a probe module and connect it to EVerest. This does not mark the module as ready yet.
|
||||
- session: runtime session information (path to EVerest installation and location of run config file)
|
||||
- module_id: the module ID to register with EVerest. By default, this will be "probe".
|
||||
- start: whether to start the module immediately. Set to false if you need to add implementations or subscriptions before starting.
|
||||
"""
|
||||
logging.info("ProbeModule init start")
|
||||
m = Module(module_id, session)
|
||||
self._setup = m.say_hello()
|
||||
self._mod = m
|
||||
self._ready_event = threading.Event()
|
||||
self._started = False
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Send the "ready" signal for the probe module.
|
||||
You should do this after implementing all commands needed in your test.
|
||||
"""
|
||||
if self._started:
|
||||
raise RuntimeError("Called start(), but ProbeModule is started already!")
|
||||
self._started = True
|
||||
# subscribe to session events
|
||||
self._mod.init_done(self._ready)
|
||||
logging.info("Probe module initialized")
|
||||
|
||||
async def call_command(self, connection_id: str, command_name: str, args: dict) -> Any:
|
||||
"""
|
||||
Call a command on another module.
|
||||
- connection_id: the id of the connection, as specified for the probe module in the runtime config
|
||||
- command_name: the name of the command to execute
|
||||
- args: the arguments for the command, as a name->value mapping
|
||||
returns: the result of the command invocation
|
||||
"""
|
||||
|
||||
interface = self._setup.connections[connection_id][0]
|
||||
try:
|
||||
async with asyncio.timeout(30):
|
||||
return await asyncio.to_thread(lambda: self._mod.call_command(interface, command_name, args))
|
||||
except TimeoutError as e:
|
||||
error_message = f"Timeout in calling {connection_id}.{command_name}: {type(e)}: {e}. This might be caused by the other module/EVerest exiting abnormally."
|
||||
logging.error(error_message)
|
||||
raise RuntimeError(error_message)
|
||||
except Exception as e:
|
||||
logging.info(
|
||||
f"Exception in calling {connection_id}.{command_name}: {type(e)}: {e}")
|
||||
|
||||
def implement_command(self, implementation_id: str, command_name: str, handler: Callable[[dict], Any]):
|
||||
"""
|
||||
Set up an implementation for a command.
|
||||
- implementation_id: the id of the implementation, as used by other modules requiring it in the runtime config
|
||||
- command_name: the name of the command to execute
|
||||
- handler: a function to handle the command, which takes a dict of arguments as input, and returns the return value as a dict (json object)
|
||||
Note: The handler runs in a separate thread!
|
||||
|
||||
|
||||
!!! WARNING: UNIMPLEMENTED COMMANDS MAY CAUSE EVEREST TO HANG !!!
|
||||
----
|
||||
In the MQTT-based protocol used by EVerest, commands are initiated by publishing requests to a specific MQTT topic.
|
||||
The implementor is subscribed to this topic, and when they are done executing a command, they publish a result on the same topic.
|
||||
To "implement" a command really just means to subscribe to the command's topic and attach a handler to process incoming requests there.
|
||||
If you do not implement a command, but another module tries to call it anyway, the command request won't reach anyone, and the caller will be stuck forever waiting for a response.
|
||||
If your tests hang, make sure you have implemented all commands which are called in the probe - the EVerest framework does not check this.
|
||||
"""
|
||||
self._mod.implement_command(implementation_id, command_name, handler)
|
||||
|
||||
def publish_variable(self, implementation_id: str, variable_name: str, value: Any):
|
||||
"""
|
||||
Publish a variable from an interface the probe module implements.
|
||||
- implementation_id: the id of the implementation, as used by other modules requiring it in the runtime config
|
||||
- variable_name: the name of the variable
|
||||
- value: the value to publish
|
||||
"""
|
||||
self._mod.publish_variable(implementation_id, variable_name, value)
|
||||
|
||||
def subscribe_variable(self, connection_id: str, variable_name: str, handler: Callable[[dict], None]):
|
||||
"""
|
||||
Subscribe to a variable implemented by a module required by the probe module.
|
||||
- connection_id: the id of the connection, as specified for the probe module in the runtime config
|
||||
- variable_name: the name of the variable
|
||||
- handler: a function to handle incoming values for the variable, accepting a dict as an input, and returning nothing.
|
||||
Note: The handler runs in a separate thread!
|
||||
"""
|
||||
self._mod.subscribe_variable(self._setup.connections[connection_id][0], variable_name, handler)
|
||||
|
||||
def subscribe_variable_to_queue(self, connection_id: str, var_name: str):
|
||||
"""
|
||||
The same as subscribe_variable, but incoming values will be pushed to a queue
|
||||
"""
|
||||
queue = Queue()
|
||||
self._mod.subscribe_variable(self._setup.connections[connection_id][0], var_name,
|
||||
lambda message, _queue=queue: _queue.put(message))
|
||||
return queue
|
||||
|
||||
def raise_error(self, implementation_id: str, error_obj: error.Error):
|
||||
"""
|
||||
Raise an error from an interface the probe module implements.
|
||||
- implementation_id: the id of the implementation, as used by other modules requiring it in the runtime config
|
||||
- error_obj: the Error object to raise
|
||||
"""
|
||||
self._mod.raise_error(implementation_id, error_obj)
|
||||
|
||||
def clear_error(self, implementation_id: str, error_type: str, sub_type: Optional[str] = None):
|
||||
"""
|
||||
Clear an error from an interface the probe module implements.
|
||||
- implementation_id: the id of the implementation, as used by other modules requiring it in the runtime config
|
||||
- error_type: the type of the error to clear
|
||||
- sub_type: optional sub-type of the error to clear
|
||||
"""
|
||||
if sub_type is not None:
|
||||
self._mod.clear_error(implementation_id, error_type, sub_type)
|
||||
else:
|
||||
self._mod.clear_error(implementation_id, error_type)
|
||||
|
||||
def subscribe_error(self, connection_id: str, error_type: str,
|
||||
callback: Callable[[error.Error], None],
|
||||
clear_callback: Callable[[error.Error], None]):
|
||||
"""
|
||||
Subscribe to a specific error type from a module required by the probe module.
|
||||
- connection_id: the id of the connection, as specified for the probe module in the runtime config
|
||||
- error_type: the type of errors to subscribe to
|
||||
- callback: a function to handle when the error is raised, accepting an Error object
|
||||
- clear_callback: a function to handle when the error is cleared, accepting an Error object
|
||||
"""
|
||||
self._mod.subscribe_error(self._setup.connections[connection_id][0], error_type, callback, clear_callback)
|
||||
|
||||
def subscribe_all_errors(self, connection_id: str,
|
||||
callback: Callable[[error.Error], None],
|
||||
clear_callback: Callable[[error.Error], None]):
|
||||
"""
|
||||
Subscribe to all errors from a module required by the probe module.
|
||||
- connection_id: the id of the connection, as specified for the probe module in the runtime config
|
||||
- callback: a function to handle when any error is raised, accepting an Error object
|
||||
- clear_callback: a function to handle when any error is cleared, accepting an Error object
|
||||
"""
|
||||
self._mod.subscribe_all_errors(self._setup.connections[connection_id][0], callback, clear_callback)
|
||||
|
||||
def _ready(self):
|
||||
"""
|
||||
Internal function: callback triggered by the EVerest framework when all modules have been initialized
|
||||
This is equivalent to the ready() method in C++ modules
|
||||
"""
|
||||
self._ready_event.set()
|
||||
|
||||
async def wait_for_event(self, timeout: float):
|
||||
"""
|
||||
Helper to make threading.Event behave similar to asyncio.Event, which is awaitable and raising TimeoutError.
|
||||
- timeout: Time to for ready_event
|
||||
"""
|
||||
self._ready_event.wait(timeout)
|
||||
if not self._ready_event.is_set():
|
||||
raise TimeoutError("Waiting for ready: timeout")
|
||||
|
||||
async def wait_to_be_ready(self, timeout=3.0):
|
||||
"""
|
||||
Convenience method which allows you to wait until the _ready() callback is triggered (i.e. until EVerest is up and running)
|
||||
"""
|
||||
if not self._started:
|
||||
raise RuntimeError("Called wait_to_be_ready(), but probe module has not been started yet! "
|
||||
"Please use start() to start the module first.")
|
||||
await self.wait_for_event(timeout)
|
||||
@@ -0,0 +1,331 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright Pionix GmbH and Contributors to EVerest
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
import time
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import wraps
|
||||
from typing import Union, Optional
|
||||
from unittest.mock import Mock
|
||||
|
||||
import websockets
|
||||
from pytest import FixtureRequest
|
||||
|
||||
from everest.testing.ocpp_utils.charge_point_utils import OcppTestConfiguration
|
||||
from ocpp.routing import create_route_map, on
|
||||
from ocpp.charge_point import ChargePoint
|
||||
|
||||
from everest.testing.ocpp_utils.charge_point_v16 import ChargePoint16
|
||||
from everest.testing.ocpp_utils.charge_point_v201 import ChargePoint201
|
||||
from everest.testing.ocpp_utils.charge_point_v21 import ChargePoint21
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.debug)
|
||||
|
||||
|
||||
class CentralSystem:
|
||||
"""Base central system used for tests to connect
|
||||
"""
|
||||
|
||||
def __init__(self, chargepoint_id, ocpp_version, port: Optional[int] = None):
|
||||
self.name = "CentralSystem"
|
||||
self.port = port
|
||||
self.chargepoint_id = chargepoint_id
|
||||
self.ocpp_version = ocpp_version
|
||||
|
||||
@abstractmethod
|
||||
async def on_connect(self, websocket):
|
||||
logging.error("'CentralSystem' did not implement 'on_connect'!")
|
||||
|
||||
@abstractmethod
|
||||
async def wait_for_chargepoint(self, timeout=30, wait_for_bootnotification=True):
|
||||
logging.error(
|
||||
"'CentralSystem' did not implement 'wait_for_chargepoint'!")
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def start(self, ssl_context=None):
|
||||
logging.error("'CentralSystem' did not implement 'start'!")
|
||||
|
||||
|
||||
class LocalCentralSystem(CentralSystem):
|
||||
"""Wrapper for CSMS websocket server. Holds a reference to a single connected chargepoint
|
||||
"""
|
||||
|
||||
def __init__(self, chargepoint_id, ocpp_version, port: Optional[int] = None):
|
||||
super().__init__(chargepoint_id, ocpp_version, port)
|
||||
self.name = "LocalCentralSystem"
|
||||
self.ws_server = None
|
||||
self.chargepoint = None
|
||||
self.chargepoint_set_event = asyncio.Event()
|
||||
self.function_overrides = []
|
||||
self.skip_validation = []
|
||||
|
||||
async def on_connect(self, websocket):
|
||||
""" For every new charge point that connects, create a ChargePoint
|
||||
instance and start listening for messages.
|
||||
"""
|
||||
path = websocket.path
|
||||
chargepoint_id = path.strip('/')
|
||||
if chargepoint_id == self.chargepoint_id:
|
||||
logging.debug(f"Chargepoint {chargepoint_id} connected")
|
||||
try:
|
||||
requested_protocols = websocket.request_headers[
|
||||
'Sec-WebSocket-Protocol']
|
||||
except KeyError:
|
||||
logging.error(
|
||||
"Client hasn't requested any Subprotocol. Closing Connection"
|
||||
)
|
||||
return await websocket.close()
|
||||
if websocket.subprotocol:
|
||||
logging.debug("Protocols Matched: %s", websocket.subprotocol)
|
||||
else:
|
||||
# In the websockets lib if no subprotocols are supported by the
|
||||
# client and the server, it proceeds without a subprotocol,
|
||||
# so we have to manually close the connection.
|
||||
logging.warning('Protocols Mismatched | Expected Subprotocols: %s,'
|
||||
' but client supports %s | Closing connection',
|
||||
websocket.available_subprotocols,
|
||||
requested_protocols)
|
||||
return await websocket.close()
|
||||
|
||||
if self.ocpp_version == 'ocpp1.6':
|
||||
cp = ChargePoint16(chargepoint_id, websocket)
|
||||
elif self.ocpp_version == 'ocpp2.0.1':
|
||||
cp = ChargePoint201(chargepoint_id, websocket)
|
||||
else:
|
||||
cp = ChargePoint21(chargepoint_id, websocket)
|
||||
self.chargepoint = cp
|
||||
self.chargepoint.pipe = True
|
||||
for override in self.function_overrides:
|
||||
setattr(self.chargepoint, override[0], override[1])
|
||||
self.chargepoint.route_map = create_route_map(self.chargepoint)
|
||||
for action in self.skip_validation:
|
||||
self.chargepoint.route_map[action]["_skip_schema_validation"] = True
|
||||
|
||||
self.chargepoint_set_event.set()
|
||||
await self.chargepoint.start()
|
||||
else:
|
||||
logging.warning(
|
||||
f"Connection on invalid path {chargepoint_id} received. Check the configuration of the ChargePointId.")
|
||||
return await websocket.close()
|
||||
|
||||
async def wait_for_chargepoint(self, timeout=30, wait_for_bootnotification=True) -> Union[ChargePoint16, ChargePoint201, ChargePoint21]:
|
||||
"""Waits for the chargepoint to connect to the CSMS
|
||||
|
||||
Args:
|
||||
timeout (int, optional): time in seconds until timeout occurs. Defaults to 30.
|
||||
wait_for_bootnotification (bool, optional): Indiciates if this method should wait until the chargepoint sends a BootNotification. Defaults to True.
|
||||
|
||||
Returns:
|
||||
ChargePoint: reference to ChargePoint16, ChargePoint201 or ChargePoint21
|
||||
"""
|
||||
try:
|
||||
logging.debug("Waiting for chargepoint to connect")
|
||||
await asyncio.wait_for(self.chargepoint_set_event.wait(), timeout)
|
||||
logging.debug("Chargepoint connected!")
|
||||
self.chargepoint_set_event.clear()
|
||||
except asyncio.exceptions.TimeoutError:
|
||||
raise asyncio.exceptions.TimeoutError(
|
||||
"Timeout while waiting for the chargepoint to connect.")
|
||||
|
||||
if wait_for_bootnotification:
|
||||
t_timeout = time.time() + timeout
|
||||
received_boot_notification = False
|
||||
while (time.time() < t_timeout and not received_boot_notification):
|
||||
raw_message = await asyncio.wait_for(self.chargepoint.wait_for_message(), timeout=timeout)
|
||||
# FIXME(piet): Make proper check for BootNotification
|
||||
received_boot_notification = "BootNotification" in raw_message
|
||||
|
||||
if not received_boot_notification:
|
||||
raise asyncio.exceptions.TimeoutError(
|
||||
"Timeout while waiting for BootNotification.")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
return self.chargepoint
|
||||
|
||||
@asynccontextmanager
|
||||
async def start(self, ssl_context=None):
|
||||
"""Starts the websocket server
|
||||
"""
|
||||
self.ws_server = await websockets.serve(
|
||||
self.on_connect,
|
||||
'0.0.0.0',
|
||||
self.port,
|
||||
subprotocols=[self.ocpp_version.value],
|
||||
ssl=ssl_context
|
||||
)
|
||||
if self.port is None:
|
||||
self.port = self.ws_server.sockets[0].getsockname()[1]
|
||||
logging.info(f"Server port was not set, setting to {self.port}")
|
||||
logging.debug(f"Server Started listening to new {self.ocpp_version} connections.")
|
||||
|
||||
yield
|
||||
|
||||
self.ws_server.close()
|
||||
await self.ws_server.wait_closed()
|
||||
|
||||
|
||||
def inject_csms_v21_mock(cs: CentralSystem) -> Mock:
|
||||
""" Given a not yet started CentralSystem, add mock overrides for _any_ action handler.
|
||||
|
||||
If not touched, those will simply proxy any request.
|
||||
|
||||
However, they allow later change of the CSMS return values:
|
||||
|
||||
Example:
|
||||
|
||||
@inject_csms_mock
|
||||
async def test_foo(central_system_v201: CentralSystem):
|
||||
central_system_v21.mock.on_get_15118_ev_certificate.side_effect = [
|
||||
call_result21.Get15118EVCertificatePayload(status=response_status,
|
||||
exi_response=exi_response)]
|
||||
"""
|
||||
|
||||
def catch_mock(mock, method_name, method):
|
||||
method_mock = getattr(mock, method_name)
|
||||
|
||||
@on(method._on_action)
|
||||
@wraps(method)
|
||||
def _method(*args, **kwargs):
|
||||
mock_res = method_mock(*args, **kwargs)
|
||||
if method_mock.side_effect:
|
||||
return mock_res
|
||||
return method(cs.chargepoint, *args, **kwargs)
|
||||
|
||||
return _method
|
||||
|
||||
mock = Mock(spec=ChargePoint21)
|
||||
charge_point_action_handlers = {
|
||||
k: v for k, v in ChargePoint21.__dict__.items() if hasattr(v, "_on_action")}
|
||||
for action_name, action_method in charge_point_action_handlers.items():
|
||||
cs.function_overrides.append(
|
||||
(action_name, catch_mock(mock, action_name, action_method)))
|
||||
return mock
|
||||
|
||||
|
||||
def inject_csms_v201_mock(cs: CentralSystem) -> Mock:
|
||||
""" Given a not yet started CentralSystem, add mock overrides for _any_ action handler.
|
||||
|
||||
If not touched, those will simply proxy any request.
|
||||
|
||||
However, they allow later change of the CSMS return values:
|
||||
|
||||
Example:
|
||||
|
||||
@inject_csms_mock
|
||||
async def test_foo(central_system_v201: CentralSystem):
|
||||
central_system_v201.mock.on_get_15118_ev_certificate.side_effect = [
|
||||
call_result201.Get15118EVCertificatePayload(status=response_status,
|
||||
exi_response=exi_response)]
|
||||
"""
|
||||
|
||||
def catch_mock(mock, method_name, method):
|
||||
method_mock = getattr(mock, method_name)
|
||||
|
||||
@on(method._on_action)
|
||||
@wraps(method)
|
||||
def _method(*args, **kwargs):
|
||||
mock_res = method_mock(*args, **kwargs)
|
||||
if method_mock.side_effect:
|
||||
return mock_res
|
||||
return method(cs.chargepoint, *args, **kwargs)
|
||||
|
||||
return _method
|
||||
|
||||
mock = Mock(spec=ChargePoint201)
|
||||
charge_point_action_handlers = {
|
||||
k: v for k, v in ChargePoint201.__dict__.items() if hasattr(v, "_on_action")}
|
||||
for action_name, action_method in charge_point_action_handlers.items():
|
||||
cs.function_overrides.append(
|
||||
(action_name, catch_mock(mock, action_name, action_method)))
|
||||
return mock
|
||||
|
||||
|
||||
def inject_csms_v16_mock(cs: CentralSystem) -> Mock:
|
||||
""" Given a not yet started CentralSystem, add mock overrides for _any_ action handler.
|
||||
|
||||
If not touched, those will simply proxy any request.
|
||||
|
||||
However, they allow later change of the CSMS return values:
|
||||
|
||||
Example:
|
||||
|
||||
@inject_csms_mock
|
||||
async def test_foo(central_system_v201: CentralSystem):
|
||||
central_system_v201.mock.on_get_15118_ev_certificate.side_effect = [
|
||||
call_result201.Get15118EVCertificatePayload(status=response_status,
|
||||
exi_response=exi_response)]
|
||||
"""
|
||||
|
||||
def catch_mock(mock, method_name, method):
|
||||
method_mock = getattr(mock, method_name)
|
||||
|
||||
@on(method._on_action)
|
||||
@wraps(method)
|
||||
def _method(*args, **kwargs):
|
||||
mock_res = method_mock(*args, **kwargs)
|
||||
if method_mock.side_effect:
|
||||
return mock_res
|
||||
return method(cs.chargepoint, *args, **kwargs)
|
||||
|
||||
return _method
|
||||
|
||||
mock = Mock(spec=ChargePoint16)
|
||||
charge_point_action_handlers = {
|
||||
k: v for k, v in ChargePoint16.__dict__.items() if hasattr(v, "_on_action")}
|
||||
for action_name, action_method in charge_point_action_handlers.items():
|
||||
cs.function_overrides.append(
|
||||
(action_name, catch_mock(mock, action_name, action_method)))
|
||||
return mock
|
||||
|
||||
|
||||
def determine_ssl_context(request: FixtureRequest, test_config: OcppTestConfiguration) -> ssl.SSLContext | None:
|
||||
""" Determine CSMS SSL Context: Default take from test_config, can be overwritten by csms_tls marker """
|
||||
|
||||
csms_tls_enabled = test_config.csms_tls_enabled
|
||||
if test_config.certificate_info:
|
||||
csms_tls_cert = test_config.certificate_info.csms_cert
|
||||
csms_tls_key = test_config.certificate_info.csms_key
|
||||
csms_tls_passphrase = test_config.certificate_info.csms_passphrase
|
||||
csms_tls_root_ca = test_config.certificate_info.csms_root_ca
|
||||
else:
|
||||
csms_tls_cert = None
|
||||
csms_tls_key = None
|
||||
csms_tls_passphrase = None
|
||||
csms_tls_root_ca = None
|
||||
csms_tls_verify_client_certificate = test_config.csms_tls_verify_client_certificate
|
||||
|
||||
if csms_tls_marker := request.node.get_closest_marker("csms_tls"):
|
||||
if csms_tls_marker.args:
|
||||
csms_tls_enabled = csms_tls_marker.args[0]
|
||||
else:
|
||||
# provided marker always enabled tls if not explicitly set to False
|
||||
csms_tls_enabled = True
|
||||
marker_kwargs = csms_tls_marker.kwargs
|
||||
if "certificate" in marker_kwargs:
|
||||
csms_tls_cert = marker_kwargs["certificate"]
|
||||
if "private_key" in marker_kwargs:
|
||||
csms_tls_key = marker_kwargs["private_key"]
|
||||
if "passphrase" in marker_kwargs:
|
||||
csms_tls_passphrase = marker_kwargs["passphrase"]
|
||||
if "root_ca" in marker_kwargs:
|
||||
csms_tls_root_ca = marker_kwargs["root_ca"]
|
||||
if "verify_client_certificate" in marker_kwargs:
|
||||
csms_tls_verify_client_certificate = marker_kwargs["verify_client_certificate"]
|
||||
|
||||
if csms_tls_enabled:
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(csms_tls_cert,
|
||||
csms_tls_key,
|
||||
csms_tls_passphrase)
|
||||
if csms_tls_verify_client_certificate:
|
||||
ssl_context.verify_mode = ssl.CERT_REQUIRED
|
||||
ssl_context.load_verify_locations(csms_tls_root_ca)
|
||||
return ssl_context
|
||||
else:
|
||||
return None
|
||||
@@ -0,0 +1,300 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2020 - 2023 Pionix GmbH and Contributors to EVerest
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import OpenSSL.crypto as crypto
|
||||
import logging
|
||||
import time
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any, Union
|
||||
from typing import Optional
|
||||
|
||||
from ocpp.messages import unpack
|
||||
from ocpp.charge_point import ChargePoint as CP
|
||||
from ocpp.charge_point import snake_to_camel_case, camel_to_snake_case, asdict, remove_nones
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChargePointInfo:
|
||||
charge_point_id: str = "cp001"
|
||||
charge_point_vendor: Optional[str] = None
|
||||
charge_point_model: Optional[str] = None
|
||||
firmware_version: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthorizationInfo:
|
||||
emaid: str
|
||||
valid_id_tag_1: str
|
||||
valid_id_tag_2: str
|
||||
invalid_id_tag: str
|
||||
parent_id_tag: str
|
||||
invalid_parent_id_tag: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CertificateInfo:
|
||||
csms_root_ca: Path
|
||||
csms_root_ca_key: Path
|
||||
csms_root_ca_invalid: Path
|
||||
csms_cert: Path
|
||||
csms_key: Path
|
||||
csms_passphrase: str
|
||||
mf_root_ca: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirmwareInfo:
|
||||
update_file: Path
|
||||
update_file_signature: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcppTestConfiguration:
|
||||
csms_tls_enabled: bool = False
|
||||
csms_tls_verify_client_certificate: bool = False
|
||||
csms_port: str = 9000
|
||||
csms_host: str = "127.0.0.1"
|
||||
charge_point_info: ChargePointInfo = field(default_factory=ChargePointInfo)
|
||||
config_path: Optional[Path] = None
|
||||
authorization_info: Optional[AuthorizationInfo] = None
|
||||
certificate_info: Optional[CertificateInfo] = None
|
||||
firmware_info: Optional[FirmwareInfo] = None
|
||||
|
||||
|
||||
class ValidationMode(str, Enum):
|
||||
STRICT = "STRICT"
|
||||
EASY = "EASY"
|
||||
|
||||
|
||||
class TestUtility:
|
||||
__test__ = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.messages = []
|
||||
self.validation_mode = ValidationMode.EASY
|
||||
self.forbidden_actions = []
|
||||
|
||||
|
||||
async def wait_for_and_validate(meta_data: TestUtility, charge_point: CP, exp_action: str,
|
||||
exp_payload, validate_payload_func=None, timeout: int = 30) -> Union[bool, Any]:
|
||||
|
||||
"""This method waits for an expected message specified by the message_type, the action and the payload to be received.
|
||||
It also considers the meta_data that contains the message history, the validation mode and forbidden actions.
|
||||
|
||||
Args:
|
||||
meta_data (TestUtility): contains the message history, the validation mode and forbidden actions that are considered in the validation
|
||||
charge_point (CP): The instance of the wrapper around the chargepoint websocket connection
|
||||
exp_action (str): The expected OCPP action (e.g. StatusNotification, BootNotification, etc.)
|
||||
exp_payload (_type_): The expected payload. Can be of type dict or can also be a call or call_result of the ocpp lib. If a dict is given,
|
||||
only the subset of the entries given in the dict will be validated
|
||||
validate_payload_func (function, optional): Optional validation function that can be used for more complex validations. Defaults to None.
|
||||
timeout (int, optional): time in seconds until waiting for the exp_payload times out. Defaults to 30.
|
||||
|
||||
Returns:
|
||||
Union[bool, Any]: True if valid message found, response if applicable, else False.
|
||||
"""
|
||||
|
||||
logging.debug(f"Waiting for {exp_action}")
|
||||
|
||||
# check if expected message has been sent already
|
||||
if (exp_message_has_already_been_sent(meta_data, exp_action, exp_payload, validate_payload_func)):
|
||||
return True
|
||||
|
||||
response = await validate_incoming_messages(
|
||||
meta_data, charge_point, exp_action, exp_payload, validate_payload_func, timeout, False
|
||||
)
|
||||
|
||||
if response:
|
||||
return response
|
||||
|
||||
logging.info("This is the message history")
|
||||
charge_point.message_history.log_history()
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_and_validate_next_message_only_with_specific_action(meta_data: TestUtility, charge_point: CP, exp_action: str,
|
||||
exp_payload, validate_payload_func=None, timeout: int = 30) -> Union[bool, Any]:
|
||||
|
||||
"""This method waits for an expected message specified by the message_type, the action and the payload to be received.
|
||||
It also considers the meta_data that contains the message history, the validation mode and forbidden actions.
|
||||
It will only check the first message with the expected action.
|
||||
|
||||
Args:
|
||||
meta_data (TestUtility): contains the message history, the validation mode and forbidden actions that are considered in the validation
|
||||
charge_point (CP): The instance of the wrapper around the chargepoint websocket connection
|
||||
exp_action (str): The expected OCPP action (e.g. StatusNotification, BootNotification, etc.)
|
||||
exp_payload (_type_): The expected payload. Can be of type dict or can also be a call or call_result of the ocpp lib. If a dict is given,
|
||||
only the subset of the entries given in the dict will be validated
|
||||
validate_payload_func (function, optional): Optional validation function that can be used for more complex validations. Defaults to None.
|
||||
timeout (int, optional): time in seconds until waiting for the exp_payload times out. Defaults to 30.
|
||||
|
||||
Returns:
|
||||
Union[bool, Any]: True if valid message found, response if applicable, else False.
|
||||
"""
|
||||
|
||||
logging.debug(f"Waiting for {exp_action}")
|
||||
|
||||
# check if expected message has been sent already
|
||||
if (exp_message_has_already_been_sent(meta_data, exp_action, exp_payload, validate_payload_func)):
|
||||
return True
|
||||
|
||||
response = await validate_incoming_messages(
|
||||
meta_data, charge_point, exp_action, exp_payload, validate_payload_func, timeout, False
|
||||
)
|
||||
|
||||
if response:
|
||||
return response
|
||||
|
||||
logging.info("This is the message history")
|
||||
charge_point.message_history.log_history()
|
||||
return False
|
||||
|
||||
|
||||
def exp_message_has_already_been_sent(meta_data: TestUtility, exp_action: str, exp_payload, validate_payload_func=None):
|
||||
if (meta_data.validation_mode == ValidationMode.EASY and
|
||||
validate_against_old_messages(meta_data,
|
||||
exp_action, exp_payload, validate_payload_func)):
|
||||
logging.debug(
|
||||
f"Found correct message {exp_action} with payload {exp_payload} in old messages")
|
||||
logging.debug("OK!")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def validate_incoming_messages(meta_data: TestUtility, charge_point: CP, exp_action: str, exp_payload, validate_payload_func=None, timeout: int = 30, check_next_only=False):
|
||||
t_timeout = time.time() + timeout
|
||||
while (time.time() < t_timeout):
|
||||
try:
|
||||
raw_message = await asyncio.wait_for(charge_point.wait_for_message(), timeout=timeout)
|
||||
charge_point.message_event.clear()
|
||||
msg = unpack(raw_message)
|
||||
if (msg.message_type_id == 4):
|
||||
logging.debug("Received CallError")
|
||||
elif (msg.action != None):
|
||||
logging.debug(f"Received Call {msg.action}")
|
||||
elif (msg.message_type_id == 3):
|
||||
logging.debug("Received CallResult")
|
||||
|
||||
meta_data.messages.append(msg)
|
||||
|
||||
response = validate_message(
|
||||
msg, exp_action, exp_payload, validate_payload_func, meta_data)
|
||||
if response != False:
|
||||
logging.debug("Message validated successfully!")
|
||||
meta_data.messages.remove(msg)
|
||||
if response:
|
||||
return response
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
if (msg.message_type_id != 4):
|
||||
logging.debug(
|
||||
f"This message {msg.action} with payload {msg.payload} was not what I waited for")
|
||||
logging.debug(f"I wait for {exp_payload}")
|
||||
# add msg to messages and wait for next message
|
||||
meta_data.messages.append(msg)
|
||||
if (check_next_only and msg.message_type_id == 2 and msg.action == exp_action):
|
||||
return False
|
||||
except asyncio.TimeoutError:
|
||||
logging.debug("Timeout while waiting for new message")
|
||||
logging.info(
|
||||
f"Timeout while waiting for correct message with action {exp_action} and payload {exp_payload}")
|
||||
return False
|
||||
|
||||
|
||||
def validate_against_old_messages(meta_data, exp_action, exp_payload, validate_payload_func=None):
|
||||
if meta_data.messages:
|
||||
for msg in meta_data.messages:
|
||||
response = validate_message(
|
||||
msg, exp_action, exp_payload, validate_payload_func, meta_data)
|
||||
if response:
|
||||
meta_data.messages.remove(msg)
|
||||
return response
|
||||
return False
|
||||
|
||||
|
||||
def contains_expected_response(expected: dict, msg_payload: dict):
|
||||
for k, v in expected.items():
|
||||
if k not in msg_payload:
|
||||
return False
|
||||
|
||||
if isinstance(v, dict):
|
||||
if not isinstance(msg_payload[k], dict):
|
||||
return False
|
||||
if not contains_expected_response(v, msg_payload[k]):
|
||||
return False
|
||||
elif msg_payload[k] != v:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_message(msg, exp_action, exp_payload, validate_payload_func, meta_data):
|
||||
|
||||
if (msg.message_type_id == 4):
|
||||
return False
|
||||
|
||||
if msg.action in meta_data.forbidden_actions:
|
||||
logging.error(
|
||||
f"Forbidden action {msg.action} was sent by the charge point")
|
||||
assert False
|
||||
|
||||
try:
|
||||
if ((msg.message_type_id == 2 and msg.action == exp_action) or msg.message_type_id == 3):
|
||||
if (validate_payload_func == None):
|
||||
if not isinstance(exp_payload, dict):
|
||||
exp_payload = asdict(exp_payload)
|
||||
exp_payload = remove_nones(snake_to_camel_case(exp_payload))
|
||||
if contains_expected_response(exp_payload, msg.payload):
|
||||
return camel_to_snake_case(msg.payload)
|
||||
elif meta_data.validation_mode == ValidationMode.STRICT and \
|
||||
msg.message_type_id != 3:
|
||||
assert False
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return validate_payload_func(meta_data, msg, exp_payload)
|
||||
|
||||
else:
|
||||
return False
|
||||
except KeyError:
|
||||
return False
|
||||
|
||||
|
||||
class HistoryMessage:
|
||||
def __init__(self, message, initiator) -> None:
|
||||
self.message = message
|
||||
self.initiator = initiator
|
||||
self.time = datetime.now()
|
||||
|
||||
|
||||
class MessageHistory:
|
||||
def __init__(self) -> None:
|
||||
self.messages = []
|
||||
|
||||
def add_received(self, message):
|
||||
self.messages.append(HistoryMessage(message, "Chargepoint"))
|
||||
|
||||
def add_send(self, message):
|
||||
self.messages.append(HistoryMessage(message, "CSMS"))
|
||||
|
||||
def log_history(self):
|
||||
for message in self.messages:
|
||||
time = message.time.strftime("%d-%m-%Y, %H:%M:%S")
|
||||
logging.info(f"{time} {message.initiator}: {message.message}")
|
||||
|
||||
|
||||
def create_cert(serial_no, not_before, not_after, ca_cert, csr, ca_private_key):
|
||||
cert = crypto.X509()
|
||||
cert.set_serial_number(serial_no)
|
||||
cert.gmtime_adj_notBefore(0)
|
||||
cert.gmtime_adj_notAfter(not_after)
|
||||
cert.set_issuer(ca_cert.get_subject())
|
||||
cert.set_subject(csr.get_subject())
|
||||
cert.set_pubkey(csr.get_pubkey())
|
||||
cert.sign(ca_private_key, 'SHA256')
|
||||
|
||||
return crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
|
||||
@@ -0,0 +1,358 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2020 - 2023 Pionix GmbH and Contributors to EVerest
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import OpenSSL.crypto as crypto
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError
|
||||
|
||||
from ocpp.messages import unpack
|
||||
from ocpp.charge_point import camel_to_snake_case, snake_to_camel_case, asdict, remove_nones
|
||||
from ocpp.v16.datatypes import (
|
||||
IdTagInfo,
|
||||
)
|
||||
from ocpp.v16 import call, call_result
|
||||
from ocpp.v16.enums import (
|
||||
Action,
|
||||
RegistrationStatus,
|
||||
AuthorizationStatus,
|
||||
GenericStatus,
|
||||
DataTransferStatus
|
||||
)
|
||||
from ocpp.v16 import ChargePoint as cp
|
||||
from ocpp.routing import on
|
||||
|
||||
# for OCPP1.6 PnC whitepaper:
|
||||
from ocpp.v201 import call_result as call_result201
|
||||
from ocpp.v201.datatypes import IdTokenInfoType
|
||||
from ocpp.v201.enums import (
|
||||
AuthorizationStatusEnumType, GenericStatusEnumType, GetCertificateStatusEnumType)
|
||||
|
||||
from everest.testing.ocpp_utils.charge_point_utils import MessageHistory, create_cert
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
class ChargePoint16(cp):
|
||||
|
||||
"""Wrapper for the OCPP1.6 chargepoint websocket client. Implementes the communication
|
||||
of messages sent from CSMS to chargepoint.
|
||||
"""
|
||||
|
||||
def __init__(self, cp_id, connection, response_timeout=30):
|
||||
super().__init__(cp_id, connection, response_timeout)
|
||||
self.pipeline = []
|
||||
self.pipe = False
|
||||
self.csr = None
|
||||
self.message_event = asyncio.Event()
|
||||
self.message_history = MessageHistory()
|
||||
|
||||
async def start(self):
|
||||
"""Start to receive, store and route incoming messages.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
message = await self._connection.recv()
|
||||
logging.debug(f"Chargepoint: \n{message}")
|
||||
self.message_history.add_received(message)
|
||||
|
||||
if (self.pipe):
|
||||
self.pipeline.append(message)
|
||||
self.message_event.set()
|
||||
|
||||
await self.route_message(message)
|
||||
self.message_event.clear()
|
||||
except ConnectionClosedOK:
|
||||
logging.debug("ConnectionClosedOK: Websocket is going down")
|
||||
except ConnectionClosedError:
|
||||
logging.debug("ConnectionClosedError: Websocket is going down")
|
||||
|
||||
async def stop(self):
|
||||
"""Drops the websocket connection
|
||||
"""
|
||||
await self._connection.close()
|
||||
|
||||
async def _send(self, message):
|
||||
"""Saves the given message to the MessageHistory and sends the message over the ws connection
|
||||
|
||||
Args:
|
||||
message (str): message
|
||||
"""
|
||||
logging.debug(f"CSMS: \n{message}")
|
||||
self.message_history.add_send(message)
|
||||
await self._connection.send(message)
|
||||
|
||||
async def wait_for_message(self):
|
||||
"""If no message is in the pipeline, this method waits for the next message.
|
||||
If there is one or more messages in the pipeline, it pops the latest message.
|
||||
"""
|
||||
if not self.pipeline:
|
||||
await self.message_event.wait()
|
||||
return self.pipeline.pop(0)
|
||||
|
||||
@on(Action.boot_notification)
|
||||
def on_boot_notification(
|
||||
self, charge_point_vendor: str, charge_point_model: str, **kwargs
|
||||
):
|
||||
logging.debug("Received a BootNotification")
|
||||
# connecting to mqtt server
|
||||
return call_result.BootNotification(
|
||||
current_time=datetime.now(timezone.utc).isoformat(),
|
||||
interval=1440,
|
||||
status=RegistrationStatus.accepted,
|
||||
)
|
||||
|
||||
@on(Action.heartbeat)
|
||||
def on_heartbeat(self, **kwargs):
|
||||
return call_result.Heartbeat(current_time=datetime.now(timezone.utc).isoformat())
|
||||
|
||||
@on(Action.authorize)
|
||||
def on_authorize(self, **kwargs):
|
||||
id_tag_info = IdTagInfo(status=AuthorizationStatus.accepted)
|
||||
return call_result.Authorize(id_tag_info=id_tag_info)
|
||||
|
||||
@on(Action.meter_values)
|
||||
def on_meter_values(self, **kwargs):
|
||||
return call_result.MeterValues()
|
||||
|
||||
@on(Action.status_notification)
|
||||
def on_status_notification(self, **kwargs):
|
||||
return call_result.StatusNotification()
|
||||
|
||||
@on(Action.start_transaction)
|
||||
def on_start_transaction(self, **kwargs):
|
||||
id_tag_info = IdTagInfo(status=AuthorizationStatus.accepted)
|
||||
return call_result.StartTransaction(transaction_id=1, id_tag_info=id_tag_info)
|
||||
|
||||
@on(Action.stop_transaction)
|
||||
def on_stop_transaction(self, **kwargs):
|
||||
return call_result.StopTransaction()
|
||||
|
||||
@on(Action.diagnostics_status_notification)
|
||||
def on_diagnostics_status_notification(self, **kwargs):
|
||||
return call_result.DiagnosticsStatusNotification()
|
||||
|
||||
@on(Action.sign_certificate)
|
||||
def on_sign_certificate(self, **kwargs):
|
||||
self.csr = kwargs['csr']
|
||||
return call_result.SignCertificate(GenericStatus.accepted)
|
||||
|
||||
@on(Action.security_event_notification)
|
||||
def on_security_event_notification(self, **kwargs):
|
||||
return call_result.SecurityEventNotification()
|
||||
|
||||
@on(Action.signed_firmware_status_notification)
|
||||
def on_signed_update_firmware_status_notificaion(self, **kwargs):
|
||||
return call_result.SignedFirmwareStatusNotification()
|
||||
|
||||
@on(Action.log_status_notification)
|
||||
def on_log_status_notification(self, **kwargs):
|
||||
return call_result.LogStatusNotification()
|
||||
|
||||
@on(Action.firmware_status_notification)
|
||||
def on_firmware_status_notification(self, **kwargs):
|
||||
return call_result.FirmwareStatusNotification()
|
||||
|
||||
@on(Action.data_transfer)
|
||||
def on_data_transfer(self, **kwargs):
|
||||
req = call.DataTransfer(**kwargs)
|
||||
if req.vendor_id == 'org.openchargealliance.iso15118pnc':
|
||||
if (req.message_id == "Authorize"):
|
||||
response = call_result201.Authorize(
|
||||
id_token_info=IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.accepted
|
||||
)
|
||||
)
|
||||
return call_result.DataTransfer(
|
||||
status=DataTransferStatus.accepted,
|
||||
data=json.dumps(remove_nones(
|
||||
snake_to_camel_case(asdict(response))))
|
||||
)
|
||||
# Should not be part of DataTransfer.req from CP->CSMS
|
||||
elif (req.message_id == "CertificateSigned"):
|
||||
return call_result.DataTransfer(
|
||||
status=DataTransferStatus.unknown_message_id,
|
||||
data="Please implement me"
|
||||
)
|
||||
# Should not be part of DataTransfer.req from CP->CSMS
|
||||
elif req.message_id == "DeleteCertificate":
|
||||
return call_result.DataTransfer(
|
||||
status=DataTransferStatus.unknown_message_id,
|
||||
data="Please implement me"
|
||||
)
|
||||
elif req.message_id == "Get15118EVCertificate":
|
||||
return call_result.DataTransfer(
|
||||
status=DataTransferStatus.unknown_message_id,
|
||||
data="Please implement me"
|
||||
)
|
||||
elif req.message_id == "GetCertificateStatus":
|
||||
return call_result.DataTransfer(
|
||||
status=DataTransferStatus.accepted,
|
||||
data=json.dumps(remove_nones(snake_to_camel_case(asdict(
|
||||
call_result201.GetCertificateStatus(
|
||||
status=GetCertificateStatusEnumType.accepted,
|
||||
ocsp_result="anwfdiefnwenfinfinef"
|
||||
)
|
||||
))))
|
||||
)
|
||||
# Should not be part of DataTransfer.req from CP->CSMS
|
||||
elif req.message_id == "InstallCertificate":
|
||||
return call_result.DataTransfer(
|
||||
status=DataTransferStatus.unknown_message_id,
|
||||
data="Please implement me"
|
||||
)
|
||||
elif req.message_id == "SignCertificate":
|
||||
return call_result.DataTransfer(
|
||||
status=DataTransferStatus.accepted,
|
||||
data=json.dumps(asdict(
|
||||
call_result201.SignCertificate(
|
||||
status=GenericStatusEnumType.accepted
|
||||
)
|
||||
))
|
||||
)
|
||||
# Should not be part of DataTransfer.req from CP->CSMS
|
||||
elif req.message_id == "TriggerMessage":
|
||||
return call_result.DataTransfer(
|
||||
status=DataTransferStatus.unknown_message_id,
|
||||
data="Please implement me"
|
||||
)
|
||||
else:
|
||||
return call_result.DataTransfer(
|
||||
status=DataTransferStatus.unknown_message_id,
|
||||
data="Please implement me"
|
||||
)
|
||||
else:
|
||||
return call_result.DataTransfer(
|
||||
status=DataTransferStatus.unknown_vendor_id,
|
||||
data="Please implement me"
|
||||
)
|
||||
|
||||
async def get_configuration_req(self, **kwargs):
|
||||
payload = call.GetConfiguration(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def change_configuration_req(self, **kwargs):
|
||||
payload = call.ChangeConfiguration(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_cache_req(self, **kwargs):
|
||||
payload = call.ClearCache()
|
||||
return await self.call(payload)
|
||||
|
||||
async def remote_start_transaction_req(self, **kwargs):
|
||||
payload = call.RemoteStartTransaction(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def remote_stop_transaction_req(self, **kwargs):
|
||||
payload = call.RemoteStopTransaction(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def unlock_connector_req(self, **kwargs):
|
||||
payload = call.UnlockConnector(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def change_availability_req(self, **kwargs):
|
||||
payload = call.ChangeAvailability(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def reset_req(self, **kwargs):
|
||||
payload = call.Reset(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_local_list_version_req(self, **kwargs):
|
||||
payload = call.GetLocalListVersion()
|
||||
return await self.call(payload)
|
||||
|
||||
async def send_local_list_req(self, **kwargs):
|
||||
payload = call.SendLocalList(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def reserve_now_req(self, **kwargs):
|
||||
payload = call.ReserveNow(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def cancel_reservation_req(self, **kwargs):
|
||||
payload = call.CancelReservation(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def trigger_message_req(self, **kwargs):
|
||||
payload = call.TriggerMessage(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_charging_profile_req(self, payload: call.SetChargingProfile):
|
||||
logging.info(payload)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_composite_schedule(self, payload: call.GetCompositeSchedule) -> call_result.GetCompositeSchedule:
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_composite_schedule_req(self, **kwargs) -> call_result.GetCompositeSchedule:
|
||||
payload = call.GetCompositeSchedule(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_charging_profile_req(self, **kwargs):
|
||||
payload = call.ClearChargingProfile(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def data_transfer_req(self, **kwargs):
|
||||
payload = call.DataTransfer(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def extended_trigger_message_req(self, **kwargs):
|
||||
payload = call.ExtendedTriggerMessage(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def certificate_signed_req(self, **kwargs):
|
||||
if 'certificate_chain' not in kwargs:
|
||||
serial_no = 1
|
||||
not_before = -86400
|
||||
not_after = 86400*365
|
||||
|
||||
ca_cert = crypto.load_certificate(crypto.FILETYPE_PEM, open(
|
||||
kwargs['csms_root_ca']).read())
|
||||
csr = crypto.load_certificate_request(
|
||||
crypto.FILETYPE_PEM, self.csr)
|
||||
ca_private_key = crypto.load_privatekey(
|
||||
crypto.FILETYPE_PEM, open(kwargs['csms_root_ca_key']).read(), str.encode('ocatool'))
|
||||
|
||||
cert = create_cert(serial_no, not_before,
|
||||
not_after, ca_cert, csr, ca_private_key)
|
||||
|
||||
payload = call.CertificateSigned(
|
||||
certificate_chain=cert.decode())
|
||||
return await self.call(payload)
|
||||
else:
|
||||
payload = call.CertificateSigned(
|
||||
certificate_chain=kwargs['certificate_chain'])
|
||||
return await self.call(payload)
|
||||
|
||||
async def install_certificate_req(self, **kwargs):
|
||||
payload = call.InstallCertificate(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_installed_certificate_ids_req(self, **kwargs):
|
||||
payload = call.GetInstalledCertificateIds(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def delete_certificate_req(self, **kwargs):
|
||||
payload = call.DeleteCertificate(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_log_req(self, **kwargs):
|
||||
payload = call.GetLog(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def signed_update_firmware_req(self, **kwargs):
|
||||
payload = call.SignedUpdateFirmware(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_diagnostics_req(self, **kwargs):
|
||||
payload = call.GetDiagnostics(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def update_firmware_req(self, **kwargs):
|
||||
payload = call.UpdateFirmware(**kwargs)
|
||||
return await self.call(payload)
|
||||
@@ -0,0 +1,361 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ocpp.routing import on
|
||||
from ocpp.v201 import ChargePoint as cp
|
||||
from ocpp.v201 import call, call_result
|
||||
from ocpp.v201.datatypes import IdTokenInfoType, SetVariableDataType, GetVariableDataType, ComponentType, VariableType
|
||||
from ocpp.v201.enums import (
|
||||
Action,
|
||||
RegistrationStatusEnumType,
|
||||
AuthorizationStatusEnumType,
|
||||
AttributeEnumType,
|
||||
NotifyEVChargingNeedsStatusEnumType,
|
||||
GenericStatusEnumType
|
||||
)
|
||||
from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError
|
||||
|
||||
from everest.testing.ocpp_utils.charge_point_utils import MessageHistory
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
class ChargePoint201(cp):
|
||||
"""Wrapper for the OCPP2.0.1 chargepoint websocket client. Implementes the communication
|
||||
of messages sent from CSMS to chargepoint.
|
||||
"""
|
||||
|
||||
def __init__(self, cp_id, connection, response_timeout=30):
|
||||
super().__init__(cp_id, connection, response_timeout)
|
||||
self.pipeline = []
|
||||
self.pipe = False
|
||||
self.csr = None
|
||||
self.message_event = asyncio.Event()
|
||||
self.message_history = MessageHistory()
|
||||
|
||||
async def start(self):
|
||||
"""Start to receive, store and route incoming messages.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
message = await self._connection.recv()
|
||||
logging.debug(f"Chargepoint: \n{message}")
|
||||
self.message_history.add_received(message)
|
||||
|
||||
if (self.pipe):
|
||||
self.pipeline.append(message)
|
||||
self.message_event.set()
|
||||
|
||||
await self.route_message(message)
|
||||
self.message_event.clear()
|
||||
except ConnectionClosedOK:
|
||||
logging.debug("ConnectionClosedOK: Websocket is going down")
|
||||
except ConnectionClosedError:
|
||||
logging.debug("ConnectionClosedError: Websocket is going down")
|
||||
|
||||
async def stop(self):
|
||||
await self._connection.close()
|
||||
|
||||
async def _send(self, message):
|
||||
logging.debug(f"CSMS: \n{message}")
|
||||
self.message_history.add_send(message)
|
||||
await self._connection.send(message)
|
||||
|
||||
async def wait_for_message(self):
|
||||
"""If no message is in the pipeline, this method waits for the next message.
|
||||
If there is one or more messages in the pipeline, it pops the latest message.
|
||||
"""
|
||||
if not self.pipeline:
|
||||
await self.message_event.wait()
|
||||
return self.pipeline.pop(0)
|
||||
|
||||
@on(Action.boot_notification)
|
||||
def on_boot_notification(self, **kwargs):
|
||||
logging.debug("Received a BootNotification")
|
||||
return call_result.BootNotification(current_time=datetime.now().isoformat(),
|
||||
interval=300, status=RegistrationStatusEnumType.accepted)
|
||||
|
||||
@on(Action.status_notification)
|
||||
def on_status_notification(self, **kwargs):
|
||||
return call_result.StatusNotification()
|
||||
|
||||
@on(Action.heartbeat)
|
||||
def on_heartbeat(self, **kwargs):
|
||||
return call_result.Heartbeat(current_time=datetime.now(timezone.utc).isoformat())
|
||||
|
||||
@on(Action.authorize)
|
||||
def on_authorize(self, **kwargs):
|
||||
return call_result.Authorize(
|
||||
id_token_info=IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.accepted
|
||||
)
|
||||
)
|
||||
|
||||
@on(Action.notify_report)
|
||||
def on_notify_report(self, **kwargs):
|
||||
return call_result.NotifyReport()
|
||||
|
||||
@on(Action.log_status_notification)
|
||||
def on_log_status_notification(self, **kwargs):
|
||||
return call_result.LogStatusNotification()
|
||||
|
||||
@on(Action.firmware_status_notification)
|
||||
def on_firmware_status_notification(self, **kwargs):
|
||||
return call_result.FirmwareStatusNotification()
|
||||
|
||||
@on(Action.transaction_event)
|
||||
def on_transaction_event(self, **kwargs):
|
||||
return call_result.TransactionEvent()
|
||||
|
||||
@on(Action.meter_values)
|
||||
def on_meter_values(self, **kwargs):
|
||||
return call_result.MeterValues()
|
||||
|
||||
@on(Action.notify_charging_limit)
|
||||
def on_notify_charging_limit(self, **kwargs):
|
||||
return call_result.NotifyChargingLimit()
|
||||
|
||||
@on(Action.notify_customer_information)
|
||||
def on_notify_customer_information(self, **kwargs):
|
||||
return call_result.NotifyCustomerInformation()
|
||||
|
||||
@on(Action.notify_ev_charging_needs)
|
||||
def on_notify_ev_charging_needs(self, **kwargs):
|
||||
return call_result.NotifyEVChargingNeeds(status=NotifyEVChargingNeedsStatusEnumType.accepted)
|
||||
|
||||
@on(Action.notify_ev_charging_schedule)
|
||||
def on_notify_ev_charging_schedule(self, **kwargs):
|
||||
return call_result.NotifyEVChargingSchedule(status=GenericStatusEnumType.accepted)
|
||||
|
||||
@on(Action.notify_event)
|
||||
def on_notify_event(self, **kwargs):
|
||||
return call_result.NotifyEvent()
|
||||
|
||||
@on(Action.notify_monitoring_report)
|
||||
def on_notify_monitoring_report(self, **kwargs):
|
||||
return call_result.NotifyMonitoringReport()
|
||||
|
||||
@on(Action.publish_firmware_status_notification)
|
||||
def on_publish_firmware_status_notification(self, **kwargs):
|
||||
return call_result.PublishFirmwareStatusNotification()
|
||||
|
||||
@on(Action.report_charging_profiles)
|
||||
def on_report_charging_profiles(self, **kwargs):
|
||||
return call_result.ReportChargingProfiles()
|
||||
|
||||
@on(Action.reservation_status_update)
|
||||
def on_reservation_status_update(self, **kwargs):
|
||||
return call_result.ReservationStatusUpdate()
|
||||
|
||||
@on(Action.security_event_notification)
|
||||
def on_security_event_notification(self, **kwargs):
|
||||
return call_result.SecurityEventNotification()
|
||||
|
||||
@on(Action.sign_certificate)
|
||||
def on_sign_certificate(self, **kwargs):
|
||||
return call_result.SignCertificate(status=GenericStatusEnumType.accepted)
|
||||
|
||||
@on(Action.get_15118_ev_certificate)
|
||||
def on_get_15118_ev_certificate(self, **kwargs):
|
||||
return call_result.Get15118EVCertificate(status=GenericStatusEnumType.accepted,
|
||||
exi_response="")
|
||||
|
||||
@on(Action.get_certificate_status)
|
||||
def on_get_certificate_status(self, **kwargs):
|
||||
return call_result.GetCertificateStatus(status=GenericStatusEnumType.accepted,
|
||||
ocsp_result="")
|
||||
|
||||
@on(Action.data_transfer)
|
||||
def on_data_transfer(self, **kwargs):
|
||||
return call_result.DataTransfer(status=GenericStatusEnumType.accepted, data="")
|
||||
|
||||
async def set_variables_req(self, **kwargs):
|
||||
payload = call.SetVariables(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_config_variables_req(self, component_name, variable_name, value):
|
||||
el = SetVariableDataType(
|
||||
attribute_value=value,
|
||||
attribute_type=AttributeEnumType.actual,
|
||||
component=ComponentType(
|
||||
name=component_name
|
||||
),
|
||||
variable=VariableType(
|
||||
name=variable_name
|
||||
)
|
||||
)
|
||||
payload = call.SetVariables([el])
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_variables_req(self, **kwargs):
|
||||
payload = call.GetVariables(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_config_variables_req(self, component_name, variable_name):
|
||||
el = GetVariableDataType(
|
||||
component=ComponentType(
|
||||
name=component_name
|
||||
),
|
||||
variable=VariableType(
|
||||
name=variable_name
|
||||
),
|
||||
attribute_type=AttributeEnumType.actual
|
||||
)
|
||||
payload = call.GetVariables([el])
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_base_report_req(self, **kwargs):
|
||||
payload = call.GetBaseReport(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_report_req(self, **kwargs):
|
||||
payload = call.GetReport(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def reset_req(self, **kwargs):
|
||||
payload = call.Reset(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def request_start_transaction_req(self, **kwargs):
|
||||
payload = call.RequestStartTransaction(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def request_stop_transaction_req(self, **kwargs):
|
||||
payload = call.RequestStopTransaction(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def change_availablility_req(self, **kwargs):
|
||||
payload = call.ChangeAvailability(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_cache_req(self, **kwargs):
|
||||
payload = call.ClearCache(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def cancel_reservation_req(self, **kwargs):
|
||||
payload = call.CancelReservation(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def certificate_signed_req(self, **kwargs):
|
||||
payload = call.CertificateSigned(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_charging_profile_req(self, **kwargs):
|
||||
payload = call.ClearChargingProfile(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_display_message_req(self, **kwargs):
|
||||
payload = call.ClearDisplayMessage(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_charging_limit_req(self, **kwargs):
|
||||
payload = call.ClearedChargingLimit(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_variable_monitoring_req(self, **kwargs):
|
||||
payload = call.ClearVariableMonitoringd(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def cost_update_req(self, **kwargs):
|
||||
payload = call.CostUpdated(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def customer_information_req(self, **kwargs):
|
||||
payload = call.CustomerInformation(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def data_transfer_req(self, **kwargs):
|
||||
payload = call.DataTransfer(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def delete_certificate_req(self, **kwargs):
|
||||
payload = call.DeleteCertificate(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_charging_profiles_req(self, **kwargs):
|
||||
payload = call.GetChargingProfiles(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_composite_schedule_req(self, **kwargs):
|
||||
payload = call.GetCompositeSchedule(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_display_nessages_req(self, **kwargs):
|
||||
payload = call.GetDisplayMessages(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_installed_certificate_ids_req(self, **kwargs):
|
||||
payload = call.GetInstalledCertificateIds(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_local_list_version(self, **kwargs):
|
||||
payload = call.GetLocalListVersion(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_log_req(self, **kwargs):
|
||||
payload = call.GetLog(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_transaction_status_req(self, **kwargs):
|
||||
payload = call.GetTransactionStatus(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def install_certificate_req(self, **kwargs):
|
||||
payload = call.InstallCertificate(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def publish_firmware_req(self, **kwargs):
|
||||
payload = call.PublishFirmware(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def reserve_now_req(self, **kwargs):
|
||||
payload = call.ReserveNow(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def send_local_list_req(self, **kwargs):
|
||||
payload = call.SendLocalList(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_charging_profile_req(self, **kwargs):
|
||||
payload = call.SetChargingProfile(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_display_message_req(self, **kwargs):
|
||||
payload = call.SetDisplayMessage(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_monitoring_base_req(self, **kwargs):
|
||||
payload = call.SetMonitoringBase(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_monitoring_level_req(self, **kwargs):
|
||||
payload = call.SetMonitoringLevel(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_network_profile_req(self, **kwargs):
|
||||
payload = call.SetNetworkProfile(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_variable_monitoring_req(self, **kwargs):
|
||||
payload = call.SetVariableMonitoring(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def trigger_message_req(self, **kwargs):
|
||||
payload = call.TriggerMessage(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def unlock_connector_req(self, **kwargs):
|
||||
payload = call.UnlockConnector(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def unpublish_firmware_req(self, **kwargs):
|
||||
payload = call.UnpublishFirmware(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def update_firmware(self, **kwargs):
|
||||
payload = call.UpdateFirmware(**kwargs)
|
||||
return await self.call(payload)
|
||||
@@ -0,0 +1,393 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ocpp.routing import on
|
||||
from ocpp.v21 import ChargePoint as cp
|
||||
from ocpp.v21 import call, call_result
|
||||
from ocpp.v21.datatypes import IdTokenInfoType, SetVariableDataType, GetVariableDataType, ComponentType, VariableType
|
||||
from ocpp.v21.enums import (
|
||||
Action,
|
||||
RegistrationStatusEnumType,
|
||||
AuthorizationStatusEnumType,
|
||||
AttributeEnumType,
|
||||
NotifyEVChargingNeedsStatusEnumType,
|
||||
GenericStatusEnumType
|
||||
)
|
||||
from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError
|
||||
|
||||
from everest.testing.ocpp_utils.charge_point_utils import MessageHistory
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
class ChargePoint21(cp):
|
||||
"""Wrapper for the OCPP2.1 chargepoint websocket client. Implementes the communication
|
||||
of messages sent from CSMS to chargepoint.
|
||||
"""
|
||||
|
||||
def __init__(self, cp_id, connection, response_timeout=30):
|
||||
super().__init__(cp_id, connection, response_timeout)
|
||||
self.pipeline = []
|
||||
self.pipe = False
|
||||
self.csr = None
|
||||
self.message_event = asyncio.Event()
|
||||
self.message_history = MessageHistory()
|
||||
|
||||
async def start(self):
|
||||
"""Start to receive, store and route incoming messages.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
message = await self._connection.recv()
|
||||
logging.debug(f"Chargepoint: \n{message}")
|
||||
self.message_history.add_received(message)
|
||||
|
||||
if (self.pipe):
|
||||
self.pipeline.append(message)
|
||||
self.message_event.set()
|
||||
|
||||
await self.route_message(message)
|
||||
self.message_event.clear()
|
||||
except ConnectionClosedOK:
|
||||
logging.debug("ConnectionClosedOK: Websocket is going down")
|
||||
except ConnectionClosedError:
|
||||
logging.debug("ConnectionClosedError: Websocket is going down")
|
||||
|
||||
async def stop(self):
|
||||
await self._connection.close()
|
||||
|
||||
async def _send(self, message):
|
||||
logging.debug(f"CSMS: \n{message}")
|
||||
self.message_history.add_send(message)
|
||||
await self._connection.send(message)
|
||||
|
||||
async def wait_for_message(self):
|
||||
"""If no message is in the pipeline, this method waits for the next message.
|
||||
If there is one or more messages in the pipeline, it pops the latest message.
|
||||
"""
|
||||
if not self.pipeline:
|
||||
await self.message_event.wait()
|
||||
return self.pipeline.pop(0)
|
||||
|
||||
@on(Action.boot_notification)
|
||||
def on_boot_notification(self, **kwargs):
|
||||
logging.debug("Received a BootNotification")
|
||||
return call_result.BootNotification(current_time=datetime.now().isoformat(),
|
||||
interval=300, status=RegistrationStatusEnumType.accepted)
|
||||
|
||||
@on(Action.status_notification)
|
||||
def on_status_notification(self, **kwargs):
|
||||
return call_result.StatusNotification()
|
||||
|
||||
@on(Action.heartbeat)
|
||||
def on_heartbeat(self, **kwargs):
|
||||
return call_result.Heartbeat(current_time=datetime.now(timezone.utc).isoformat())
|
||||
|
||||
@on(Action.authorize)
|
||||
def on_authorize(self, **kwargs):
|
||||
return call_result.Authorize(
|
||||
id_token_info=IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.accepted
|
||||
)
|
||||
)
|
||||
|
||||
@on(Action.notify_report)
|
||||
def on_notify_report(self, **kwargs):
|
||||
return call_result.NotifyReport()
|
||||
|
||||
@on(Action.log_status_notification)
|
||||
def on_log_status_notification(self, **kwargs):
|
||||
return call_result.LogStatusNotification()
|
||||
|
||||
@on(Action.firmware_status_notification)
|
||||
def on_firmware_status_notification(self, **kwargs):
|
||||
return call_result.FirmwareStatusNotification()
|
||||
|
||||
@on(Action.transaction_event)
|
||||
def on_transaction_event(self, **kwargs):
|
||||
return call_result.TransactionEvent()
|
||||
|
||||
@on(Action.meter_values)
|
||||
def on_meter_values(self, **kwargs):
|
||||
return call_result.MeterValues()
|
||||
|
||||
@on(Action.notify_charging_limit)
|
||||
def on_notify_charging_limit(self, **kwargs):
|
||||
return call_result.NotifyChargingLimit()
|
||||
|
||||
@on(Action.notify_customer_information)
|
||||
def on_notify_customer_information(self, **kwargs):
|
||||
return call_result.NotifyCustomerInformation()
|
||||
|
||||
@on(Action.notify_ev_charging_needs)
|
||||
def on_notify_ev_charging_needs(self, **kwargs):
|
||||
return call_result.NotifyEVChargingNeeds(status=NotifyEVChargingNeedsStatusEnumType.accepted)
|
||||
|
||||
@on(Action.notify_ev_charging_schedule)
|
||||
def on_notify_ev_charging_schedule(self, **kwargs):
|
||||
return call_result.NotifyEVChargingSchedule(status=GenericStatusEnumType.accepted)
|
||||
|
||||
@on(Action.notify_event)
|
||||
def on_notify_event(self, **kwargs):
|
||||
return call_result.NotifyEvent()
|
||||
|
||||
@on(Action.notify_monitoring_report)
|
||||
def on_notify_monitoring_report(self, **kwargs):
|
||||
return call_result.NotifyMonitoringReport()
|
||||
|
||||
@on(Action.publish_firmware_status_notification)
|
||||
def on_publish_firmware_status_notification(self, **kwargs):
|
||||
return call_result.PublishFirmwareStatusNotification()
|
||||
|
||||
@on(Action.report_charging_profiles)
|
||||
def on_report_charging_profiles(self, **kwargs):
|
||||
return call_result.ReportChargingProfiles()
|
||||
|
||||
@on(Action.reservation_status_update)
|
||||
def on_reservation_status_update(self, **kwargs):
|
||||
return call_result.ReservationStatusUpdate()
|
||||
|
||||
@on(Action.security_event_notification)
|
||||
def on_security_event_notification(self, **kwargs):
|
||||
return call_result.SecurityEventNotification()
|
||||
|
||||
@on(Action.sign_certificate)
|
||||
def on_sign_certificate(self, **kwargs):
|
||||
return call_result.SignCertificate(status=GenericStatusEnumType.accepted)
|
||||
|
||||
@on(Action.get15118_ev_certificate)
|
||||
def on_get_15118_ev_certificate(self, **kwargs):
|
||||
return call_result.Get15118EVCertificate(status=GenericStatusEnumType.accepted,
|
||||
exi_response="")
|
||||
|
||||
@on(Action.get_certificate_status)
|
||||
def on_get_certificate_status(self, **kwargs):
|
||||
return call_result.GetCertificateStatus(status=GenericStatusEnumType.accepted,
|
||||
ocsp_result="")
|
||||
|
||||
@on(Action.data_transfer)
|
||||
def on_data_transfer(self, **kwargs):
|
||||
return call_result.DataTransfer(status=GenericStatusEnumType.accepted, data="")
|
||||
|
||||
async def set_variables_req(self, **kwargs):
|
||||
payload = call.SetVariables(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_config_variables_req(self, component_name, variable_name, value):
|
||||
el = SetVariableDataType(
|
||||
attribute_value=value,
|
||||
attribute_type=AttributeEnumType.actual,
|
||||
component=ComponentType(
|
||||
name=component_name
|
||||
),
|
||||
variable=VariableType(
|
||||
name=variable_name
|
||||
)
|
||||
)
|
||||
payload = call.SetVariables([el])
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_variables_req(self, **kwargs):
|
||||
payload = call.GetVariables(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_config_variables_req(self, component_name, variable_name):
|
||||
el = GetVariableDataType(
|
||||
component=ComponentType(
|
||||
name=component_name
|
||||
),
|
||||
variable=VariableType(
|
||||
name=variable_name
|
||||
),
|
||||
attribute_type=AttributeEnumType.actual
|
||||
)
|
||||
payload = call.GetVariables([el])
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_base_report_req(self, **kwargs):
|
||||
payload = call.GetBaseReport(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_report_req(self, **kwargs):
|
||||
payload = call.GetReport(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def reset_req(self, **kwargs):
|
||||
payload = call.Reset(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def request_start_transaction_req(self, **kwargs):
|
||||
payload = call.RequestStartTransaction(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def request_stop_transaction_req(self, **kwargs):
|
||||
payload = call.RequestStopTransaction(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def change_availablility_req(self, **kwargs):
|
||||
payload = call.ChangeAvailability(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_cache_req(self, **kwargs):
|
||||
payload = call.ClearCache(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def cancel_reservation_req(self, **kwargs):
|
||||
payload = call.CancelReservation(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def certificate_signed_req(self, **kwargs):
|
||||
payload = call.CertificateSigned(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_charging_profile_req(self, **kwargs):
|
||||
payload = call.ClearChargingProfile(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_display_message_req(self, **kwargs):
|
||||
payload = call.ClearDisplayMessage(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_charging_limit_req(self, **kwargs):
|
||||
payload = call.ClearedChargingLimit(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_variable_monitoring_req(self, **kwargs):
|
||||
payload = call.ClearVariableMonitoringd(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def cost_update_req(self, **kwargs):
|
||||
payload = call.CostUpdated(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def customer_information_req(self, **kwargs):
|
||||
payload = call.CustomerInformation(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def data_transfer_req(self, **kwargs):
|
||||
payload = call.DataTransfer(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def delete_certificate_req(self, **kwargs):
|
||||
payload = call.DeleteCertificate(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_charging_profiles_req(self, **kwargs):
|
||||
payload = call.GetChargingProfiles(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_composite_schedule_req(self, **kwargs):
|
||||
payload = call.GetCompositeSchedule(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_display_nessages_req(self, **kwargs):
|
||||
payload = call.GetDisplayMessages(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_installed_certificate_ids_req(self, **kwargs):
|
||||
payload = call.GetInstalledCertificateIds(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_local_list_version(self, **kwargs):
|
||||
payload = call.GetLocalListVersion(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_log_req(self, **kwargs):
|
||||
payload = call.GetLog(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_transaction_status_req(self, **kwargs):
|
||||
payload = call.GetTransactionStatus(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def install_certificate_req(self, **kwargs):
|
||||
payload = call.InstallCertificate(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def publish_firmware_req(self, **kwargs):
|
||||
payload = call.PublishFirmware(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def reserve_now_req(self, **kwargs):
|
||||
payload = call.ReserveNow(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def send_local_list_req(self, **kwargs):
|
||||
payload = call.SendLocalList(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_charging_profile_req(self, **kwargs):
|
||||
payload = call.SetChargingProfile(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_display_message_req(self, **kwargs):
|
||||
payload = call.SetDisplayMessage(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_monitoring_base_req(self, **kwargs):
|
||||
payload = call.SetMonitoringBase(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_monitoring_level_req(self, **kwargs):
|
||||
payload = call.SetMonitoringLevel(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_network_profile_req(self, **kwargs):
|
||||
payload = call.SetNetworkProfile(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def set_variable_monitoring_req(self, **kwargs):
|
||||
payload = call.SetVariableMonitoring(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def trigger_message_req(self, **kwargs):
|
||||
payload = call.TriggerMessage(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def unlock_connector_req(self, **kwargs):
|
||||
payload = call.UnlockConnector(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def unpublish_firmware_req(self, **kwargs):
|
||||
payload = call.UnpublishFirmware(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def update_firmware(self, **kwargs):
|
||||
payload = call.UpdateFirmware(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def notify_allowed_energy_transfer_request(self, **kwargs):
|
||||
payload = call.NotifyAllowedEnergyTransfer(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
# --- OCPP 2.1 R04: DER Control (CSMS -> CS) ---
|
||||
|
||||
async def set_der_control_req(self, **kwargs):
|
||||
payload = call.SetDERControl(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def get_der_control_req(self, **kwargs):
|
||||
payload = call.GetDERControl(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
async def clear_der_control_req(self, **kwargs):
|
||||
payload = call.ClearDERControl(**kwargs)
|
||||
return await self.call(payload)
|
||||
|
||||
# --- OCPP 2.1 R04: DER Control (CS -> CSMS) response handlers ---
|
||||
|
||||
@on(Action.notify_der_start_stop)
|
||||
def on_notify_der_start_stop(self, **kwargs):
|
||||
return call_result.NotifyDERStartStop()
|
||||
|
||||
@on(Action.notify_der_alarm)
|
||||
def on_notify_der_alarm(self, **kwargs):
|
||||
return call_result.NotifyDERAlarm()
|
||||
|
||||
@on(Action.report_der_control)
|
||||
def on_report_der_control(self, **kwargs):
|
||||
return call_result.ReportDERControl()
|
||||
@@ -0,0 +1,227 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2020 - 2023 Pionix GmbH and Contributors to EVerest
|
||||
import getpass
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from pyftpdlib import servers
|
||||
from pyftpdlib.authorizers import DummyAuthorizer
|
||||
from pyftpdlib.handlers import FTPHandler
|
||||
|
||||
from everest.testing.core_utils.common import OCPPVersion
|
||||
from everest.testing.core_utils._configuration.everest_environment_setup import EverestEnvironmentOCPPConfiguration
|
||||
from everest.testing.core_utils.controller.everest_test_controller import EverestTestController
|
||||
from everest.testing.ocpp_utils.central_system import CentralSystem, LocalCentralSystem, inject_csms_v201_mock, inject_csms_v16_mock, \
|
||||
determine_ssl_context, inject_csms_v21_mock
|
||||
from everest.testing.ocpp_utils.charge_point_utils import TestUtility, OcppTestConfiguration
|
||||
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".")))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ocpp_version(request) -> OCPPVersion:
|
||||
ocpp_version = request.node.get_closest_marker("ocpp_version")
|
||||
if ocpp_version:
|
||||
return OCPPVersion(request.node.get_closest_marker("ocpp_version").args[0])
|
||||
else:
|
||||
return OCPPVersion("ocpp1.6")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ocpp_config(request, central_system: CentralSystem, test_config: OcppTestConfiguration, ocpp_version: OCPPVersion):
|
||||
ocpp_config_marker = request.node.get_closest_marker("ocpp_config")
|
||||
|
||||
ocpp_configuration_strategies_marker = request.node.get_closest_marker(
|
||||
"ocpp_config_adaptions")
|
||||
ocpp_configuration_strategies = []
|
||||
if ocpp_configuration_strategies_marker:
|
||||
for v in ocpp_configuration_strategies_marker.args:
|
||||
assert hasattr(v,
|
||||
"adjust_ocpp_configuration"), "Arguments to 'ocpp_config_adaptions' must all provide interface of OCPPConfigAdjustmentStrategy"
|
||||
ocpp_configuration_strategies.append(v)
|
||||
|
||||
return EverestEnvironmentOCPPConfiguration(
|
||||
central_system_port=central_system.port,
|
||||
central_system_host="127.0.0.1",
|
||||
ocpp_version=ocpp_version,
|
||||
template_ocpp_config=Path(
|
||||
ocpp_config_marker.args[0]) if ocpp_config_marker else None,
|
||||
device_model_component_config_path=Path(f"{request.config.getoption('--everest-prefix')}/share/everest/modules/OCPP201/component_config"),
|
||||
configuration_strategies=ocpp_configuration_strategies
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def central_system(request, ocpp_version: OCPPVersion, test_config):
|
||||
"""Fixture for CentralSystem. Can be started as TLS or
|
||||
plain websocket depending on the request parameter.
|
||||
"""
|
||||
|
||||
ssl_context = determine_ssl_context(request, test_config)
|
||||
|
||||
central_system_marker = request.node.get_closest_marker(
|
||||
'custom_central_system')
|
||||
|
||||
if central_system_marker:
|
||||
assert isinstance(central_system_marker.args[0], CentralSystem)
|
||||
cs = central_system_marker.args[0]
|
||||
else:
|
||||
cs = LocalCentralSystem(test_config.charge_point_info.charge_point_id,
|
||||
ocpp_version=ocpp_version)
|
||||
|
||||
if request.node.get_closest_marker('inject_csms_mock'):
|
||||
if ocpp_version == OCPPVersion.ocpp201:
|
||||
mock = inject_csms_v201_mock(cs)
|
||||
elif ocpp_version == OCPPVersion.ocpp16:
|
||||
mock = inject_csms_v16_mock(cs)
|
||||
else:
|
||||
mock = inject_csms_v21_mock(cs)
|
||||
cs.mock = mock
|
||||
|
||||
async with cs.start(ssl_context):
|
||||
yield cs
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def charge_point(central_system: CentralSystem, test_controller: EverestTestController):
|
||||
"""Fixture for ChargePoint16. Requires central_system_v201 and test_controller. Starts test_controller immediately
|
||||
"""
|
||||
test_controller.start()
|
||||
cp = await central_system.wait_for_chargepoint()
|
||||
yield cp
|
||||
await cp.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_utility():
|
||||
"""Fixture for test case meta data
|
||||
"""
|
||||
return TestUtility()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_config():
|
||||
return OcppTestConfiguration()
|
||||
|
||||
|
||||
class FtpThread(Thread):
|
||||
def __init__(self, directory, port, test_config: OcppTestConfiguration, ftp_socket,
|
||||
group=None, target=None, name=None, args=..., kwargs=None, *, daemon=None):
|
||||
super().__init__(group, target, name, args, kwargs, daemon=daemon)
|
||||
self.directory = directory
|
||||
self.port = port
|
||||
self.test_config = test_config
|
||||
self.ftp_socket = ftp_socket
|
||||
|
||||
def set_directory(self, directory):
|
||||
self.directory = directory
|
||||
|
||||
def set_port(self, port):
|
||||
self.port = port
|
||||
|
||||
def set_test_config(self, test_config: OcppTestConfiguration):
|
||||
self.test_config = test_config
|
||||
|
||||
def set_socket(self, ftp_socket):
|
||||
self.ftp_socket = ftp_socket
|
||||
|
||||
def stop(self):
|
||||
self.server.close_all()
|
||||
|
||||
def run(self):
|
||||
shutil.copyfile(self.test_config.firmware_info.update_file, os.path.join(
|
||||
self.directory, "firmware_update.pnx"))
|
||||
shutil.copyfile(self.test_config.firmware_info.update_file_signature,
|
||||
os.path.join(self.directory, "firmware_update.pnx.base64"))
|
||||
|
||||
authorizer = DummyAuthorizer()
|
||||
authorizer.add_user(getpass.getuser(), "12345",
|
||||
self.directory, perm="elradfmwMT")
|
||||
|
||||
handler = FTPHandler
|
||||
handler.authorizer = authorizer
|
||||
|
||||
self.server = servers.FTPServer(self.ftp_socket, handler)
|
||||
|
||||
self.server.serve_forever()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ftp_server(test_config: OcppTestConfiguration):
|
||||
"""This fixture creates a temporary directory and starts
|
||||
a local ftp server connected to that directory. The temporary
|
||||
directory is deleted afterwards
|
||||
"""
|
||||
|
||||
d = tempfile.mkdtemp(prefix='tmp_ftp')
|
||||
address = ("127.0.0.1", 0)
|
||||
ftp_socket = socket.socket()
|
||||
ftp_socket.bind(address)
|
||||
port = ftp_socket.getsockname()[1]
|
||||
|
||||
ftp_thread = FtpThread(directory=d, port=port,
|
||||
test_config=test_config, ftp_socket=ftp_socket)
|
||||
ftp_thread.daemon = True
|
||||
ftp_thread.start()
|
||||
|
||||
yield ftp_thread
|
||||
|
||||
ftp_thread.stop()
|
||||
|
||||
shutil.rmtree(d)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def central_system_v16(central_system):
|
||||
""" Note: This is only for backwards compatibility; use central_system directly! """
|
||||
yield central_system
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def central_system_v201(central_system):
|
||||
""" Note: This is only for backwards compatibility; use central_system directly! """
|
||||
yield central_system
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def central_system_v21(central_system):
|
||||
""" Note: This is only for backwards compatibility; use central_system directly! """
|
||||
yield central_system
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def charge_point_v16(charge_point):
|
||||
""" Note: This is only for backwards compatibility; use charge_point directly! """
|
||||
yield charge_point
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def charge_point_v201(charge_point):
|
||||
""" Note: This is only for backwards compatibility; use charge_point directly! """
|
||||
yield charge_point
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def charge_point_v21(charge_point):
|
||||
""" Note: This is only for backwards compatibility; use charge_point directly! """
|
||||
yield charge_point
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def central_system_v16_standalone(request, central_system: CentralSystem, test_controller: EverestTestController):
|
||||
""" Note: This is only for backwards compatibility; use central_system + test_controller directly!
|
||||
|
||||
Fixture for standalone central system. Requires central_system_v16 and test_controller. Starts test_controller immediately
|
||||
"""
|
||||
test_controller.start()
|
||||
yield central_system
|
||||
test_controller.stop()
|
||||
Reference in New Issue
Block a user