diff --git a/iso15118/evcc/states/iso15118_20_states.py b/iso15118/evcc/states/iso15118_20_states.py index 5cf00404..0b94329c 100644 --- a/iso15118/evcc/states/iso15118_20_states.py +++ b/iso15118/evcc/states/iso15118_20_states.py @@ -24,6 +24,7 @@ Namespace, ParameterName, ServiceV20, + SessionStopAction, ) from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 from iso15118.shared.messages.iso15118_20.ac import ( @@ -1125,11 +1126,16 @@ async def process_message( if not msg: return + session_stop_reason = self.comm_session.charging_session_stop_v20.lower() + if session_stop_reason == "pause": + session_stop_action = SessionStopAction.PAUSE + else: + session_stop_action = SessionStopAction.TERMINATE self.comm_session.stop_reason = StopNotification( True, - f"Communication session " - f"{self.comm_session.charging_session_stop_v20.lower()}d", + f"Communication session " f"{session_stop_reason}d", self.comm_session.writer.get_extra_info("peername"), + session_stop_action, ) if ( diff --git a/iso15118/secc/comm_session_handler.py b/iso15118/secc/comm_session_handler.py index 47e77689..716b00e7 100644 --- a/iso15118/secc/comm_session_handler.py +++ b/iso15118/secc/comm_session_handler.py @@ -16,7 +16,11 @@ from asyncio.streams import StreamReader, StreamWriter from typing import Dict, List, Optional, Tuple, Union -from iso15118.secc.controller.interface import EVSEControllerInterface, ServiceStatus +from iso15118.secc.controller.interface import ( + EVSEControllerInterface, + EVSessionContext15118, + ServiceStatus, +) from iso15118.secc.failed_responses import ( init_failed_responses_din_spec_70121, init_failed_responses_iso_v2, @@ -34,6 +38,7 @@ ISOV2PayloadTypes, ISOV20PayloadTypes, Protocol, + SessionStopAction, ) from iso15118.shared.messages.iso15118_2.datatypes import ( CertificateChain as CertificateChainV2, @@ -125,6 +130,7 @@ def __init__( # CurrentDemandRes. The SECC must send a copy in the MeteringReceiptReq # TODO Add support for ISO 15118-20 MeterInfo self.sent_meter_info: Optional[MeterInfoV2] = None + self.ev_session_context: EVSessionContext15118 = EVSessionContext15118() self.is_tls = self._is_tls(transport) def save_session_info(self): @@ -283,7 +289,9 @@ async def get_from_rcv_queue(self, queue: asyncio.Queue): self.comm_sessions[notification.ip_address] = (comm_session, task) elif isinstance(notification, StopNotification): try: - await self.end_current_session(notification.peer_ip_address) + await self.end_current_session( + notification.peer_ip_address, notification.stop_action + ) except KeyError: pass else: @@ -296,11 +304,18 @@ async def get_from_rcv_queue(self, queue: asyncio.Queue): finally: queue.task_done() - async def end_current_session(self, peer_ip_address: str): + async def end_current_session( + self, peer_ip_address: str, session_stop_action: SessionStopAction + ): try: - await cancel_task(self.comm_sessions[peer_ip_address][1]) - del self.comm_sessions[peer_ip_address] + if session_stop_action == SessionStopAction.TERMINATE: + del self.comm_sessions[peer_ip_address] + else: + logger.debug( + f"Preserved session state: {self.comm_sessions[peer_ip_address][0].ev_session_context}" # noqa + ) await cancel_task(self.tcp_server_handler) + await cancel_task(self.comm_sessions[peer_ip_address][1]) except Exception as e: logger.warning(f"Unexpected error ending current session: {e}") diff --git a/iso15118/secc/controller/interface.py b/iso15118/secc/controller/interface.py index 2f29481d..27f4f481 100644 --- a/iso15118/secc/controller/interface.py +++ b/iso15118/secc/controller/interface.py @@ -28,6 +28,7 @@ SAScheduleTupleEntry as SAScheduleTupleEntryDINSPEC, ) from iso15118.shared.messages.enums import ( + AuthEnum, AuthorizationStatus, AuthorizationTokenType, ControlMode, @@ -41,6 +42,7 @@ from iso15118.shared.messages.iso15118_2.datatypes import ( ACEVSEChargeParameter, ACEVSEStatus, + ChargeService, ) from iso15118.shared.messages.iso15118_2.datatypes import MeterInfo as MeterInfoV2 from iso15118.shared.messages.iso15118_2.datatypes import SAScheduleTuple @@ -132,6 +134,22 @@ class EVChargeParamsLimits: ev_energy_request: Optional[PVEVEnergyRequest] = None +@dataclass +class EVSessionContext15118: + # EVSessionContext15118 holds information required to resume a paused session. + # [V2G2-741] - In a resumed session, the following are reused: + # 1. SessionID (SessionSetup) + # 2. PaymentOption that was previously selected (ServiceDiscoveryRes) + # 3. ChargeService (ServiceDiscoveryRes) + # 4. SAScheduleTuple (ChargeParameterDiscoveryRes) - + # Previously selected id must remain the same. + # However, the entries could reflect the elapsed time + session_id: Optional[str] = None + auth_options: Optional[List[AuthEnum]] = None + charge_service: Optional[ChargeService] = None + sa_schedule_tuple_id: Optional[int] = None + + class EVSEControllerInterface(ABC): def __init__(self): self.ev_data_context = EVDataContext() diff --git a/iso15118/secc/states/iso15118_20_states.py b/iso15118/secc/states/iso15118_20_states.py index e784a7b5..582f2b7d 100644 --- a/iso15118/secc/states/iso15118_20_states.py +++ b/iso15118/secc/states/iso15118_20_states.py @@ -27,6 +27,7 @@ ParameterName, Protocol, ServiceV20, + SessionStopAction, ) from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 from iso15118.shared.messages.iso15118_20.ac import ( @@ -1138,11 +1139,11 @@ async def process_message( and await evse_controller.service_renegotiation_supported() ): next_state = ServiceDiscoveryReq - stopped = "paused" + session_stop_state = SessionStopAction.PAUSE elif session_stop_req.charging_session == ChargingSession.TERMINATE: - stopped = "terminated" + session_stop_state = SessionStopAction.TERMINATE else: - stopped = "paused" + session_stop_state = SessionStopAction.PAUSE termination_info = "" if ( @@ -1157,8 +1158,10 @@ async def process_message( self.comm_session.stop_reason = StopNotification( True, - f"Communication session {stopped}. EV Info: {termination_info}", + f"Communication session {session_stop_state.value}d. " + f"EV Info: {termination_info}", self.comm_session.writer.get_extra_info("peername"), + session_stop_state, ) session_stop_res = SessionStopRes( diff --git a/iso15118/secc/states/iso15118_2_states.py b/iso15118/secc/states/iso15118_2_states.py index 86732ee1..e25e499f 100644 --- a/iso15118/secc/states/iso15118_2_states.py +++ b/iso15118/secc/states/iso15118_2_states.py @@ -10,7 +10,10 @@ from typing import List, Optional, Type, Union from iso15118.secc.comm_session_handler import SECCCommunicationSession -from iso15118.secc.controller.interface import EVChargeParamsLimits +from iso15118.secc.controller.interface import ( + EVChargeParamsLimits, + EVSessionContext15118, +) from iso15118.secc.states.secc_state import StateSECC from iso15118.shared.exceptions import ( CertAttributeError, @@ -39,6 +42,7 @@ IsolationLevel, Namespace, Protocol, + SessionStopAction, ) from iso15118.shared.messages.iso15118_2.body import ( EMAID, @@ -167,7 +171,12 @@ async def process_message( if msg.header.session_id == bytes(1).hex(): # A new charging session is established self.response_code = ResponseCode.OK_NEW_SESSION_ESTABLISHED - elif msg.header.session_id == self.comm_session.session_id: + self.comm_session.ev_session_context = EVSessionContext15118() + self.comm_session.ev_session_context.session_id = session_id + elif ( + self.comm_session.ev_session_context.session_id + and msg.header.session_id == self.comm_session.ev_session_context.session_id + ): # The EV wants to resume the previously paused charging session session_id = self.comm_session.session_id self.response_code = ResponseCode.OK_OLD_SESSION_JOINED @@ -179,6 +188,8 @@ async def process_message( f"New session ID {session_id} assigned" ) self.response_code = ResponseCode.OK_NEW_SESSION_ESTABLISHED + self.comm_session.ev_session_context = EVSessionContext15118() + self.comm_session.ev_session_context.session_id = session_id session_setup_res = SessionSetupRes( response_code=self.response_code, @@ -286,7 +297,13 @@ async def get_services( value is not standardized in any way """ auth_options: List[AuthEnum] = [] - if self.comm_session.selected_auth_option: + + if self.comm_session.ev_session_context.auth_options: + logger.info("AuthOptions available in context. This is a resumed session.") + # This is a resumed session. + # Return the auth option that was previously selected. + auth_options = self.comm_session.ev_session_context.auth_options + elif self.comm_session.selected_auth_option: # In case the EVCC resumes a paused charging session, the SECC # must only offer the auth option the EVCC selected previously if self.comm_session.selected_auth_option == AuthEnum.EIM_V2: @@ -311,15 +328,22 @@ async def get_services( ) ) - charge_service = ChargeService( - service_id=ServiceID.CHARGING, - service_name=ServiceName.CHARGING, - service_category=ServiceCategory.CHARGING, - free_service=self.comm_session.config.free_charging_service, - supported_energy_transfer_mode=EnergyTransferModeList( - energy_modes=energy_modes - ), - ) + if self.comm_session.ev_session_context.charge_service: + logger.info( + "ChargeService available in context. This is a resumed session." + ) + charge_service = self.comm_session.ev_session_context.charge_service + else: + charge_service = ChargeService( + service_id=ServiceID.CHARGING, + service_name=ServiceName.CHARGING, + service_category=ServiceCategory.CHARGING, + free_service=self.comm_session.config.free_charging_service, + supported_energy_transfer_mode=EnergyTransferModeList( + energy_modes=energy_modes + ), + ) + self.comm_session.ev_session_context.charge_service = charge_service service_list: List[ServiceDetails] = [] # Value-added services (VAS), like installation of contract certificates @@ -590,6 +614,9 @@ async def process_message( self.comm_session.selected_auth_option = AuthEnum( service_selection_req.selected_auth_option.value ) + self.comm_session.ev_session_context.auth_options: List[AuthEnum] = [ + self.comm_session.selected_auth_option + ] # For now, we don't really care much more about the selected # value-added services. If the EVCC wants to do contract certificate @@ -1274,6 +1301,30 @@ async def process_message( sa_schedule_list_valid = self.validate_sa_schedule_list( sa_schedule_list, departure_time ) + + if ( + sa_schedule_list_valid + and self.comm_session.ev_session_context.sa_schedule_tuple_id + ): + filtered_list = list( + filter( + lambda schedule_entry: schedule_entry.sa_schedule_tuple_id + == self.comm_session.ev_session_context.sa_schedule_tuple_id, + sa_schedule_list, + ) + ) + if len(filtered_list) != 1: + logger.warning( + f"Resumed session. Previously selected sa_schedule_list is" + f" not present {sa_schedule_list}" + ) + else: + logger.info( + f"Resumed session. SAScheduleTupleID " + f"{self.comm_session.ev_session_context.sa_schedule_tuple_id} " + f"present in context" + ) + if not sa_schedule_list_valid: # V2G2-305 : It is still acceptable if the sum of the schedule entry # durations falls short of departure_time requested by the EVCC in @@ -1537,6 +1588,7 @@ async def process_message( # no later than 3s after measuring CP State C or D. # Before closing the contactor, we need to check to # ensure the CP is in state C or D + if not await self.wait_for_state_c_or_d(): logger.warning( "C2/D2 CP state not detected after 250ms in PowerDelivery" @@ -1891,16 +1943,23 @@ async def process_message( msg = self.check_msg_v2(message, [SessionStopReq]) if not msg: return - session_status = msg.body.session_stop_req.charging_session.lower() - self.comm_session.stop_reason = StopNotification( - True, - f"EV Requested to {session_status} the communication session", - self.comm_session.writer.get_extra_info("peername"), - ) + if msg.body.session_stop_req.charging_session == ChargingSession.PAUSE: next_state = Pause + session_stop_state = SessionStopAction.PAUSE else: next_state = Terminate + session_stop_state = SessionStopAction.TERMINATE + # EVSessionContext stores information for resuming a paused session. + # As Terminate is requested, clear context information. + self.comm_session.ev_session_context = None + + self.comm_session.stop_reason = StopNotification( + True, + f"EV requested to {session_stop_state.value} the communication session", + self.comm_session.writer.get_extra_info("peername"), + session_stop_state, + ) self.create_next_message( next_state, SessionStopRes(response_code=ResponseCode.OK), diff --git a/iso15118/shared/notifications.py b/iso15118/shared/notifications.py index 13222fde..13e999b3 100644 --- a/iso15118/shared/notifications.py +++ b/iso15118/shared/notifications.py @@ -1,6 +1,8 @@ from asyncio.streams import StreamReader, StreamWriter from typing import Tuple +from iso15118.shared.messages.enums import SessionStopAction + class Notification: """ @@ -60,7 +62,14 @@ class StopNotification(Notification): server is serving. """ - def __init__(self, successful: bool, reason: str, peer_ip_address: str = None): + def __init__( + self, + successful: bool, + reason: str, + peer_ip_address: str = None, + stop_action: SessionStopAction = SessionStopAction.TERMINATE, + ): self.successful = successful self.reason = reason self.peer_ip_address = peer_ip_address + self.stop_action = stop_action diff --git a/iso15118/shared/pki/configs/contractLeafCert.cnf b/iso15118/shared/pki/configs/contractLeafCert.cnf index 8a0f832c..8268cf2d 100644 --- a/iso15118/shared/pki/configs/contractLeafCert.cnf +++ b/iso15118/shared/pki/configs/contractLeafCert.cnf @@ -3,7 +3,7 @@ prompt = no distinguished_name = ca_dn [ca_dn] -commonName = UKSWI123456789A +commonName = UKSWI123456791A organizationName = Switch countryName = UK domainComponent = MO diff --git a/tests/conftest.py b/tests/conftest.py index 8679a9ae..53349b55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,12 +6,13 @@ from iso15118.evcc.comm_session_handler import EVCCCommunicationSession from iso15118.evcc.controller.simulator import SimEVController from iso15118.secc.comm_session_handler import SECCCommunicationSession +from iso15118.secc.controller.interface import EVSessionContext15118 from iso15118.secc.controller.simulator import SimEVSEController from iso15118.secc.failed_responses import init_failed_responses_iso_v2 from iso15118.shared.messages.enums import Protocol from iso15118.shared.messages.iso15118_2.datatypes import EnergyTransferModeEnum from iso15118.shared.notifications import StopNotification -from tests.secc.states.test_messages import get_sa_schedule_list +from tests.iso15118_2.secc.states.test_messages import get_sa_schedule_list from tests.tools import MOCK_SESSION_ID @@ -40,4 +41,5 @@ def comm_secc_session_mock(): comm_session_mock.evse_controller = SimEVSEController() comm_session_mock.protocol = Protocol.UNKNOWN comm_session_mock.evse_id = "UK123E1234" + comm_session_mock.ev_session_context = EVSessionContext15118() return comm_session_mock diff --git a/tests/dinspec/secc/test_dinspec_secc_states.py b/tests/dinspec/secc/test_dinspec_secc_states.py index d7a6d6e1..aec8b3f5 100644 --- a/tests/dinspec/secc/test_dinspec_secc_states.py +++ b/tests/dinspec/secc/test_dinspec_secc_states.py @@ -3,6 +3,7 @@ import pytest from iso15118.secc.comm_session_handler import SECCCommunicationSession +from iso15118.secc.controller.interface import EVSessionContext15118 from iso15118.secc.controller.simulator import SimEVSEController from iso15118.secc.states.din_spec_states import CurrentDemand, PowerDelivery from iso15118.shared.messages.enums import EnergyTransferModeEnum, Protocol @@ -29,6 +30,7 @@ def _comm_session(self): self.comm_session.protocol = Protocol.UNKNOWN self.comm_session.evse_id = "UK123E1234" self.comm_session.writer = MockWriter() + self.comm_session.ev_session_context = EVSessionContext15118() async def test_sap_to_billing(self): pass diff --git a/tests/evcc/__init__.py b/tests/iso15118_2/__init__.py similarity index 100% rename from tests/evcc/__init__.py rename to tests/iso15118_2/__init__.py diff --git a/tests/evcc/states/__init__.py b/tests/iso15118_2/evcc/__init__.py similarity index 100% rename from tests/evcc/states/__init__.py rename to tests/iso15118_2/evcc/__init__.py diff --git a/tests/secc/__init__.py b/tests/iso15118_2/evcc/states/__init__.py similarity index 100% rename from tests/secc/__init__.py rename to tests/iso15118_2/evcc/states/__init__.py diff --git a/tests/evcc/states/test_iso15118_2_states.py b/tests/iso15118_2/evcc/states/test_iso15118_2_states.py similarity index 97% rename from tests/evcc/states/test_iso15118_2_states.py rename to tests/iso15118_2/evcc/states/test_iso15118_2_states.py index aa63d6df..6e4b0cd6 100644 --- a/tests/evcc/states/test_iso15118_2_states.py +++ b/tests/iso15118_2/evcc/states/test_iso15118_2_states.py @@ -9,7 +9,7 @@ ) from iso15118.shared.messages.iso15118_2.datatypes import ChargingSession from iso15118.shared.notifications import StopNotification -from tests.evcc.states.test_messages import ( +from tests.iso15118_2.evcc.states.test_messages import ( get_v2g_message_current_demand_res, get_v2g_message_current_demand_res_with_stop_charging, get_v2g_message_power_delivery_res, diff --git a/tests/evcc/states/test_messages.py b/tests/iso15118_2/evcc/states/test_messages.py similarity index 100% rename from tests/evcc/states/test_messages.py rename to tests/iso15118_2/evcc/states/test_messages.py diff --git a/tests/secc/states/__init__.py b/tests/iso15118_2/sample_certs/__init__.py similarity index 100% rename from tests/secc/states/__init__.py rename to tests/iso15118_2/sample_certs/__init__.py diff --git a/tests/sample_certs/contractLeafCert.der b/tests/iso15118_2/sample_certs/contractLeafCert.der similarity index 100% rename from tests/sample_certs/contractLeafCert.der rename to tests/iso15118_2/sample_certs/contractLeafCert.der diff --git a/tests/sample_certs/load_certs.py b/tests/iso15118_2/sample_certs/load_certs.py similarity index 100% rename from tests/sample_certs/load_certs.py rename to tests/iso15118_2/sample_certs/load_certs.py diff --git a/tests/sample_certs/moRootCACert.der b/tests/iso15118_2/sample_certs/moRootCACert.der similarity index 100% rename from tests/sample_certs/moRootCACert.der rename to tests/iso15118_2/sample_certs/moRootCACert.der diff --git a/tests/sample_certs/moRootCACert_no_ocsp.der b/tests/iso15118_2/sample_certs/moRootCACert_no_ocsp.der similarity index 100% rename from tests/sample_certs/moRootCACert_no_ocsp.der rename to tests/iso15118_2/sample_certs/moRootCACert_no_ocsp.der diff --git a/tests/sample_certs/moSubCA1Cert.der b/tests/iso15118_2/sample_certs/moSubCA1Cert.der similarity index 100% rename from tests/sample_certs/moSubCA1Cert.der rename to tests/iso15118_2/sample_certs/moSubCA1Cert.der diff --git a/tests/sample_certs/moSubCA2Cert.der b/tests/iso15118_2/sample_certs/moSubCA2Cert.der similarity index 100% rename from tests/sample_certs/moSubCA2Cert.der rename to tests/iso15118_2/sample_certs/moSubCA2Cert.der diff --git a/tests/iso15118_2/secc/__init__.py b/tests/iso15118_2/secc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/secc/messages/15118_2_invalid_messages.py b/tests/iso15118_2/secc/messages/15118_2_invalid_messages.py similarity index 100% rename from tests/secc/messages/15118_2_invalid_messages.py rename to tests/iso15118_2/secc/messages/15118_2_invalid_messages.py diff --git a/tests/iso15118_2/secc/messages/__init__.py b/tests/iso15118_2/secc/messages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/iso15118_2/secc/states/__init__.py b/tests/iso15118_2/secc/states/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/secc/states/test_iso15118_2_states.py b/tests/iso15118_2/secc/states/test_iso15118_2_states.py similarity index 77% rename from tests/secc/states/test_iso15118_2_states.py rename to tests/iso15118_2/secc/states/test_iso15118_2_states.py index 7ee4376a..5583e4d1 100644 --- a/tests/secc/states/test_iso15118_2_states.py +++ b/tests/iso15118_2/secc/states/test_iso15118_2_states.py @@ -1,9 +1,11 @@ from pathlib import Path +from typing import List from unittest.mock import AsyncMock, Mock, patch import pytest from iso15118.secc import Config +from iso15118.secc.controller.interface import EVSessionContext15118 from iso15118.secc.states.iso15118_2_states import ( Authorization, ChargeParameterDiscovery, @@ -13,6 +15,8 @@ PowerDelivery, ServiceDetail, ServiceDiscovery, + SessionSetup, + SessionStop, Terminate, WeldingDetection, ) @@ -22,18 +26,25 @@ AuthEnum, AuthorizationStatus, AuthorizationTokenType, + EnergyTransferModeEnum, EVSEProcessing, + Protocol, ) from iso15118.shared.messages.iso15118_2.body import ResponseCode from iso15118.shared.messages.iso15118_2.datatypes import ( ACEVSEStatus, + AuthOptionList, CertificateChain, + ChargeService, + EnergyTransferModeList, ServiceCategory, ServiceDetails, + ServiceID, ServiceName, ) from iso15118.shared.security import get_random_bytes -from tests.secc.states.test_messages import ( +from iso15118.shared.states import Pause +from tests.iso15118_2.secc.states.test_messages import ( get_charge_parameter_discovery_req_message_departure_time_one_hour, get_charge_parameter_discovery_req_message_no_departure_time, get_dummy_charging_status_req, @@ -48,10 +59,15 @@ get_power_delivery_req_charging_profile_in_limits, get_power_delivery_req_charging_profile_not_in_limits_span_over_sa, get_power_delivery_req_charging_profile_out_of_boundary, + get_v2g_message_charge_parameter_discovery_req, get_v2g_message_power_delivery_req, get_v2g_message_power_delivery_req_charging_profile_in_boundary_valid, get_v2g_message_service_detail_req, + get_v2g_message_service_discovery_req, + get_v2g_message_session_setup_from_pause, + get_v2g_message_session_stop_with_pause, ) +from tests.tools import MOCK_SESSION_ID @patch("iso15118.shared.states.EXI.to_exi", new=Mock(return_value=b"01")) @@ -61,6 +77,7 @@ class TestV2GSessionScenarios: def _comm_session(self, comm_secc_session_mock): self.comm_session = comm_secc_session_mock self.comm_session.config = Config() + self.comm_session.ev_session_context = EVSessionContext15118() self.comm_session.is_tls = False self.comm_session.writer = Mock() self.comm_session.writer.get_extra_info = Mock() @@ -541,3 +558,155 @@ async def test_service_detail_service_id_is_in_offered_list( service_details.message.body.service_detail_res.response_code is response_code ) + + async def test_session_pause(self): + session_stop_state = SessionStop(self.comm_session) + await session_stop_state.process_message( + message=get_v2g_message_session_stop_with_pause() + ) + assert session_stop_state.next_state is Pause + + @pytest.mark.parametrize( + "ev_session_context, session_id, response_code", + [ + ( + EVSessionContext15118(), + "00", + ResponseCode.OK_NEW_SESSION_ESTABLISHED, + ), + ( + EVSessionContext15118(session_id=MOCK_SESSION_ID), + MOCK_SESSION_ID, + ResponseCode.OK_OLD_SESSION_JOINED, + ), + ( + EVSessionContext15118(session_id=MOCK_SESSION_ID), + "ABCDEF123456", + ResponseCode.OK_NEW_SESSION_ESTABLISHED, + ), + ], + ) + async def test_session_wakeup(self, ev_session_context, session_id, response_code): + self.comm_session.ev_session_context = ev_session_context + session_setup = SessionSetup(self.comm_session) + await session_setup.process_message( + message=get_v2g_message_session_setup_from_pause(session_id) + ) + assert session_setup.response_code is response_code + assert session_setup.next_state is ServiceDiscovery + + @pytest.mark.parametrize( + "ev_session_context, auth_options, charge_service", + [ + ( + EVSessionContext15118( + session_id=MOCK_SESSION_ID, auth_options=[AuthEnum.PNC_V2] + ), + [AuthEnum.PNC_V2], + ChargeService( + service_id=ServiceID.CHARGING, + service_name=ServiceName.CHARGING, + service_category=ServiceCategory.CHARGING, + free_service=False, + supported_energy_transfer_mode=EnergyTransferModeList( + energy_modes=[ + EnergyTransferModeEnum.DC_EXTENDED, + EnergyTransferModeEnum.AC_THREE_PHASE_CORE, + ] + ), + ), + ), + ( + EVSessionContext15118( + session_id=MOCK_SESSION_ID, auth_options=[AuthEnum.EIM_V2] + ), + [AuthEnum.EIM_V2], + ChargeService( + service_id=ServiceID.CHARGING, + service_name=ServiceName.CHARGING, + service_category=ServiceCategory.CHARGING, + free_service=False, + supported_energy_transfer_mode=EnergyTransferModeList( + energy_modes=[ + EnergyTransferModeEnum.DC_EXTENDED, + EnergyTransferModeEnum.AC_THREE_PHASE_CORE, + ] + ), + ), + ), + ], + ) + async def test_resumed_session_auth_options_charge_service( + self, + ev_session_context: EVSessionContext15118, + auth_options: List[AuthEnum], + charge_service: ChargeService, + ): + self.comm_session.ev_session_context = ev_session_context + service_discovery = ServiceDiscovery(self.comm_session) + await service_discovery.process_message( + message=get_v2g_message_service_discovery_req() + ) + assert ( + service_discovery.message.body.service_discovery_res.charge_service + == charge_service + ) + + assert ( + service_discovery.message.body.service_discovery_res.auth_option_list + == AuthOptionList(auth_options=auth_options) + ) + + @pytest.mark.parametrize( + "ev_session_context, schedule_tuple_id, match_status", + [ + ( + EVSessionContext15118( + session_id=MOCK_SESSION_ID, sa_schedule_tuple_id=1 + ), + 1, + True, + ), + ( + EVSessionContext15118( + session_id=MOCK_SESSION_ID, sa_schedule_tuple_id=2 + ), + 2, + False, + ), + ], + ) + async def test_resumed_session_sa_schedule_tuple( + self, + ev_session_context: EVSessionContext15118, + schedule_tuple_id: int, + match_status: bool, + ): + self.comm_session.ev_session_context = ev_session_context + charge_parameter_discovery = ChargeParameterDiscovery(self.comm_session) + energy_transfer_modes = ( + await self.comm_session.evse_controller.get_supported_energy_transfer_modes( + Protocol.ISO_15118_2 + ) + ) + await charge_parameter_discovery.process_message( + message=get_v2g_message_charge_parameter_discovery_req( + energy_transfer_modes[0] + ) + ) + sa_schedule_list = ( + charge_parameter_discovery.message.body.charge_parameter_discovery_res.sa_schedule_list.schedule_tuples # noqa + ) + + filtered_list = list( + filter( + lambda schedule_entry: schedule_entry.sa_schedule_tuple_id + == schedule_tuple_id, + sa_schedule_list, + ) + ) + + if match_status: + assert len(filtered_list) == 1 + else: + assert len(filtered_list) == 0 diff --git a/tests/secc/states/test_messages.py b/tests/iso15118_2/secc/states/test_messages.py similarity index 87% rename from tests/secc/states/test_messages.py rename to tests/iso15118_2/secc/states/test_messages.py index 6304717d..c29c2dc1 100644 --- a/tests/secc/states/test_messages.py +++ b/tests/iso15118_2/secc/states/test_messages.py @@ -3,7 +3,9 @@ from iso15118.shared.messages.datatypes import ( PVEAmount, PVEVMaxCurrent, + PVEVMaxCurrentLimit, PVEVMaxVoltage, + PVEVMaxVoltageLimit, PVEVMinCurrent, ) from iso15118.shared.messages.enums import EnergyTransferModeEnum, UnitSymbol @@ -16,6 +18,7 @@ PowerDeliveryReq, ServiceDetailReq, ServiceDiscoveryReq, + SessionSetupReq, SessionStopReq, WeldingDetectionReq, ) @@ -24,6 +27,7 @@ ChargeProgress, ChargingProfile, ChargingSession, + DCEVChargeParameter, DCEVErrorCode, DCEVStatus, PMaxSchedule, @@ -37,7 +41,7 @@ ) from iso15118.shared.messages.iso15118_2.header import MessageHeader from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage -from tests.sample_certs.load_certs import load_certificate_chain +from tests.iso15118_2.sample_certs.load_certs import load_certificate_chain from tests.tools import MOCK_SESSION_ID @@ -517,3 +521,70 @@ def get_v2g_message_service_detail_req(service_id) -> V2GMessage: header=MessageHeader(session_id=MOCK_SESSION_ID), body=Body(service_detail_req=service_detail_req), ) + + +def get_v2g_message_session_stop_with_pause() -> V2GMessage: + session_stop_pause_req = SessionStopReq(ChargingSession=ChargingSession.PAUSE) + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body(session_stop_req=session_stop_pause_req), + ) + + +def get_v2g_message_session_setup_from_pause(session_id: str) -> V2GMessage: + session_setup_req = SessionSetupReq(evcc_id="ABCDEF123456") + return V2GMessage( + header=MessageHeader(session_id=session_id), + body=Body(session_setup_req=session_setup_req), + ) + + +def get_v2g_message_service_discovery_req() -> V2GMessage: + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body(service_discovery_req=ServiceDiscoveryReq()), + ) + + +def get_v2g_message_charge_parameter_discovery_req( + energy_transfer_mode: EnergyTransferModeEnum, +) -> V2GMessage: + ac_cp = None + dc_cp = None + if energy_transfer_mode.startswith("AC"): + ac_cp = ACEVChargeParameter( + e_amount=PVEAmount(value=1, multiplier=1, unit=UnitSymbol.WATT_HOURS), + ev_max_voltage=PVEVMaxVoltage( + value=1, multiplier=1, unit=UnitSymbol.VOLTAGE + ), + ev_max_current=PVEVMaxCurrent( + value=2, multiplier=1, unit=UnitSymbol.AMPERE + ), + ev_min_current=PVEVMinCurrent( + value=1, multiplier=1, unit=UnitSymbol.AMPERE + ), + ) + else: + dc_cp = DCEVChargeParameter( + dc_ev_status=DCEVStatus( + ev_ready=True, + ev_error_code=DCEVErrorCode.NO_ERROR, + ev_ress_soc=50, + ), + ev_maximum_current_limit=PVEVMaxCurrentLimit( + value=1, multiplier=1, unit=UnitSymbol.AMPERE + ), + ev_maximum_voltage_limit=PVEVMaxVoltageLimit( + value=1, multiplier=1, unit=UnitSymbol.VOLTAGE + ), + ) + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body( + charge_parameter_discovery_req=ChargeParameterDiscoveryReq( + requested_energy_mode=energy_transfer_mode, + ac_ev_charge_parameter=ac_cp, + dc_ev_charge_parameter=dc_cp, + ) + ), + ) diff --git a/tests/test_security.py b/tests/iso15118_2/test_security.py similarity index 99% rename from tests/test_security.py rename to tests/iso15118_2/test_security.py index 994df91a..d7a3b7c8 100644 --- a/tests/test_security.py +++ b/tests/iso15118_2/test_security.py @@ -8,7 +8,7 @@ derive_certificate_hash_data, get_certificate_hash_data, ) -from tests.sample_certs.load_certs import ( +from tests.iso15118_2.sample_certs.load_certs import ( load_certificate_chain, load_contract_certificate, load_no_ocsp_root_certificate,