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