Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter

- CitrineOS core extracted (CSMS OCPP 2.0.1)
- OpenOCPP extracted (firmware OCPP 1.6J/2.0.1)
- ShapeShifter library installed (pip install -e)
- ShapeShifter specification extracted
- EVerest extracted

TODO updated with progress
This commit is contained in:
Eric F
2026-06-08 00:38:27 -04:00
parent 468cfeaa50
commit d398a6ced2
7326 changed files with 1177561 additions and 7 deletions

View File

@@ -0,0 +1 @@
__version__ = '0.7.3'

View File

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

View File

@@ -0,0 +1,4 @@
"""
See base class `EverestConfigAdjustmentStrategy`
"""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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