diff --git a/iso15118/secc/controller/interface.py b/iso15118/secc/controller/interface.py index 3a710005..8c1a262b 100644 --- a/iso15118/secc/controller/interface.py +++ b/iso15118/secc/controller/interface.py @@ -392,7 +392,7 @@ async def is_contactor_opened(self) -> bool: raise NotImplementedError @abstractmethod - async def is_contactor_closed(self) -> bool: + async def is_contactor_closed(self) -> Optional[bool]: """ Sends a command to the SECC to get the contactor status is closed diff --git a/iso15118/secc/controller/simulator.py b/iso15118/secc/controller/simulator.py index 6bbc1c7f..ec958ba1 100644 --- a/iso15118/secc/controller/simulator.py +++ b/iso15118/secc/controller/simulator.py @@ -727,7 +727,7 @@ async def service_renegotiation_supported(self) -> bool: """Overrides EVSEControllerInterface.service_renegotiation_supported().""" return False - async def is_contactor_closed(self) -> bool: + async def is_contactor_closed(self) -> Optional[bool]: """Overrides EVSEControllerInterface.is_contactor_closed().""" return True diff --git a/iso15118/secc/states/din_spec_states.py b/iso15118/secc/states/din_spec_states.py index 5c518b91..3afb80d8 100644 --- a/iso15118/secc/states/din_spec_states.py +++ b/iso15118/secc/states/din_spec_states.py @@ -438,7 +438,9 @@ class CableCheck(StateSECC): def __init__(self, comm_session: SECCCommunicationSession): super().__init__(comm_session, Timeouts.V2G_SECC_SEQUENCE_TIMEOUT) - self.cable_check_req_was_received = False + self.cable_check_req_was_received: bool = False + self.cable_check_started: bool = False + self.contactors_closed_for_cable_check: Optional[bool] = None async def process_message( self, @@ -467,11 +469,58 @@ async def process_message( ) return + evse_processing: EVSEProcessing = EVSEProcessing.ONGOING + response_code: ResponseCode = ResponseCode.OK + next_state = None if not self.cable_check_req_was_received: - # First CableCheckReq received. Start cable check. - await self.comm_session.evse_controller.start_cable_check() + # Requirement in 6.4.3.106 of the IEC 61851-23 + # Any relays in the DC output circuit of the DC station shall + # be closed during the insulation test + self.contactors_closed_for_cable_check = ( + await self.comm_session.evse_controller.is_contactor_closed() + ) self.cable_check_req_was_received = True + if self.contactors_closed_for_cable_check is not None: + if not self.contactors_closed_for_cable_check: + self.stop_state_machine( + "Contactor didnt close for Cable Check", + message, + ResponseCode.FAILED, + ) + return + + if self.cable_check_started: + isolation_level = ( + await self.comm_session.evse_controller.get_cable_check_status() + ) # noqa + + evse_processing = EVSEProcessing.ONGOING + next_state = None + if isolation_level in [ + IsolationLevel.VALID, + IsolationLevel.WARNING, + ]: + if isolation_level == IsolationLevel.WARNING: + logger.warning( + "Isolation resistance measured by EVSE is in Warning-Range" + ) + evse_processing = EVSEProcessing.FINISHED + next_state = PreCharge + elif isolation_level in [ + IsolationLevel.FAULT, + IsolationLevel.INVALID, + ]: + self.stop_state_machine( + f"Isolation Failure: {isolation_level}", + message, + ResponseCode.FAILED, + ) + return + else: + await self.comm_session.evse_controller.start_cable_check() + self.cable_check_started = True + self.comm_session.evse_controller.ev_data_context.present_soc = ( cable_check_req.dc_ev_status.ev_ress_soc ) @@ -483,36 +532,6 @@ async def process_message( # [V2G-DC-418] Stay in CableCheck state until EVSEProcessing is complete. # Until EVSEProcessing is completed, EV will send identical # CableCheckReq message. - - evse_processing: EVSEProcessing = EVSEProcessing.ONGOING - response_code: ResponseCode = ResponseCode.OK - next_state: Type["State"] = None - if isolation_level in [ - IsolationLevel.VALID, - IsolationLevel.WARNING, - ]: - # Requirement in 6.4.3.106 of the IEC 61851-23 - # Any relays in the DC output circuit of the DC station shall - # be closed during the insulation test - if not await self.comm_session.evse_controller.is_contactor_closed(): - self.stop_state_machine( - "Contactor didnt close for Cable Check", - message, - ResponseCode.FAILED, - ) - return - - if isolation_level == IsolationLevel.WARNING: - logger.warning( - "Isolation resistance measured by EVSE is in Warning-Range" - ) - next_state = PreCharge - evse_processing = EVSEProcessing.FINISHED - elif isolation_level in [IsolationLevel.FAULT, IsolationLevel.NO_IMD]: - response_code = ResponseCode.FAILED - next_state = Terminate - evse_processing = EVSEProcessing.FINISHED - cable_check_res: CableCheckRes = CableCheckRes( response_code=response_code, dc_evse_status=await self.comm_session.evse_controller.get_dc_evse_status(), diff --git a/iso15118/secc/states/iso15118_20_states.py b/iso15118/secc/states/iso15118_20_states.py index a354451f..a8b1be01 100644 --- a/iso15118/secc/states/iso15118_20_states.py +++ b/iso15118/secc/states/iso15118_20_states.py @@ -1586,6 +1586,8 @@ class DCCableCheck(StateSECC): def __init__(self, comm_session: SECCCommunicationSession): super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) self.cable_check_req_was_received = False + self.cable_check_started = False + self.contactors_closed_for_cable_check: Optional[bool] = None async def process_message( self, @@ -1608,22 +1610,20 @@ async def process_message( await SessionStop(self.comm_session).process_message(message, message_exi) return - if not self.cable_check_req_was_received: - # First DCCableCheckReq received. Start cable check. - await self.comm_session.evse_controller.start_cable_check() - self.cable_check_req_was_received = True - next_state = None processing = EVSEProcessing.ONGOING - isolation_level = ( - await self.comm_session.evse_controller.get_cable_check_status() - ) - if isolation_level in [IsolationLevel.VALID, IsolationLevel.WARNING]: + if not self.cable_check_req_was_received: # Requirement in 6.4.3.106 of the IEC 61851-23 # Any relays in the DC output circuit of the DC station shall # be closed during the insulation test - if not await self.comm_session.evse_controller.is_contactor_closed(): + self.contactors_closed_for_cable_check = ( + await self.comm_session.evse_controller.is_contactor_closed() + ) + self.cable_check_req_was_received = True + + if self.contactors_closed_for_cable_check is not None: + if not self.contactors_closed_for_cable_check: self.stop_state_machine( "Contactor didnt close for Cable Check", message, @@ -1631,19 +1631,28 @@ async def process_message( ) return - if isolation_level == IsolationLevel.WARNING: - logger.warning( - "Isolation resistance measured by EVSE is in Warning range" + if self.cable_check_started: + isolation_level = ( + await self.comm_session.evse_controller.get_cable_check_status() ) - next_state = DCPreCharge - processing = EVSEProcessing.FINISHED - elif isolation_level in [IsolationLevel.INVALID, IsolationLevel.FAULT]: - self.stop_state_machine( - f"Isolation Failure: {isolation_level}", - message, - ResponseCode.FAILED, - ) - return + + if isolation_level in [IsolationLevel.VALID, IsolationLevel.WARNING]: + if isolation_level == IsolationLevel.WARNING: + logger.warning( + "Isolation resistance measured by EVSE is in Warning range" + ) + next_state = DCPreCharge + processing = EVSEProcessing.FINISHED + elif isolation_level in [IsolationLevel.INVALID, IsolationLevel.FAULT]: + self.stop_state_machine( + f"Isolation Failure: {isolation_level}", + message, + ResponseCode.FAILED, + ) + return + else: + await self.comm_session.evse_controller.start_cable_check() + self.cable_check_started = True dc_cable_check_res = DCCableCheckRes( header=MessageHeader( diff --git a/iso15118/secc/states/iso15118_2_states.py b/iso15118/secc/states/iso15118_2_states.py index 5a0cae7a..fd35995c 100644 --- a/iso15118/secc/states/iso15118_2_states.py +++ b/iso15118/secc/states/iso15118_2_states.py @@ -2187,6 +2187,8 @@ class CableCheck(StateSECC): def __init__(self, comm_session: SECCCommunicationSession): super().__init__(comm_session, Timeouts.V2G_SECC_SEQUENCE_TIMEOUT) self.cable_check_req_was_received = False + self.cable_check_started = False + self.contactors_closed_for_cable_check: Optional[bool] = None async def process_message( self, @@ -2214,29 +2216,20 @@ async def process_message( ) return - if not self.cable_check_req_was_received: - # First CableCheckReq received. Start cable check. - await self.comm_session.evse_controller.start_cable_check() - self.cable_check_req_was_received = True - - self.comm_session.evse_controller.ev_data_context.present_soc = ( - cable_check_req.dc_ev_status.ev_ress_soc - ) - - isolation_level = ( - await self.comm_session.evse_controller.get_cable_check_status() - ) # noqa - - evse_processing = EVSEProcessing.ONGOING next_state = None - if isolation_level in [ - IsolationLevel.VALID, - IsolationLevel.WARNING, - ]: + evse_processing = EVSEProcessing.ONGOING + + if not self.cable_check_req_was_received: # Requirement in 6.4.3.106 of the IEC 61851-23 # Any relays in the DC output circuit of the DC station shall # be closed during the insulation test - if not await self.comm_session.evse_controller.is_contactor_closed(): + self.contactors_closed_for_cable_check = ( + await self.comm_session.evse_controller.is_contactor_closed() + ) + self.cable_check_req_was_received = True + + if self.contactors_closed_for_cable_check is not None: + if not self.contactors_closed_for_cable_check: self.stop_state_machine( "Contactor didnt close for Cable Check", message, @@ -2244,22 +2237,41 @@ async def process_message( ) return - if isolation_level == IsolationLevel.WARNING: - logger.warning( - "Isolation resistance measured by EVSE is in Warning-Range" - ) - evse_processing = EVSEProcessing.FINISHED - next_state = PreCharge - elif isolation_level in [ - IsolationLevel.FAULT, - IsolationLevel.NO_IMD, - ]: - self.stop_state_machine( - f"Isolation Failure: {isolation_level}", - message, - ResponseCode.FAILED, - ) - return + if self.cable_check_started: + isolation_level = ( + await self.comm_session.evse_controller.get_cable_check_status() + ) # noqa + + evse_processing = EVSEProcessing.ONGOING + next_state = None + if isolation_level in [ + IsolationLevel.VALID, + IsolationLevel.WARNING, + ]: + if isolation_level == IsolationLevel.WARNING: + logger.warning( + "Isolation resistance measured by EVSE is in Warning-Range" + ) + evse_processing = EVSEProcessing.FINISHED + next_state = PreCharge + elif isolation_level in [ + IsolationLevel.FAULT, + IsolationLevel.NO_IMD, + IsolationLevel.INVALID, + ]: + self.stop_state_machine( + f"Isolation Failure: {isolation_level}", + message, + ResponseCode.FAILED, + ) + return + else: + await self.comm_session.evse_controller.start_cable_check() + self.cable_check_started = True + + self.comm_session.evse_controller.ev_data_context.present_soc = ( + cable_check_req.dc_ev_status.ev_ress_soc + ) cable_check_res = CableCheckRes( response_code=ResponseCode.OK, diff --git a/tests/dinspec/secc/test_dinspec_secc_states.py b/tests/dinspec/secc/test_dinspec_secc_states.py index 3fd3a0e2..de1dad59 100644 --- a/tests/dinspec/secc/test_dinspec_secc_states.py +++ b/tests/dinspec/secc/test_dinspec_secc_states.py @@ -1,3 +1,4 @@ +from typing import Type from unittest.mock import AsyncMock, Mock, patch import pytest @@ -12,9 +13,24 @@ EVSESessionLimits, ) 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 +from iso15118.secc.failed_responses import init_failed_responses_din_spec_70121 +from iso15118.secc.states.din_spec_states import ( + CableCheck, + CurrentDemand, + PowerDelivery, + PreCharge, +) +from iso15118.shared.messages.din_spec.body import Body, CableCheckReq +from iso15118.shared.messages.din_spec.datatypes import DCEVStatus, IsolationLevel +from iso15118.shared.messages.din_spec.header import MessageHeader +from iso15118.shared.messages.din_spec.msgdef import V2GMessage +from iso15118.shared.messages.enums import ( + DCEVErrorCode, + EnergyTransferModeEnum, + Protocol, +) from iso15118.shared.notifications import StopNotification +from iso15118.shared.states import State, Terminate class MockWriter: @@ -39,6 +55,9 @@ def _comm_session(self): self.comm_session.writer = MockWriter() self.comm_session.ev_session_context = EVSessionContext15118() self.comm_session.evse_controller.evse_data_context = self.get_evse_data() + self.comm_session.failed_responses_din_spec = ( + init_failed_responses_din_spec_70121() + ) def get_evse_data(self) -> EVSEDataContext: dc_limits = EVSEDCCPDLimits( @@ -113,3 +132,82 @@ async def test_power_delivery_req_set_hlc_charging( await power_delivery.process_message(message=power_delivery_req_charge_stop) self.comm_session.evse_controller.set_hlc_charging.assert_called_with(False) + + @pytest.mark.parametrize( + "cable_check_req_received, " + "is_contactor_closed, " + "cable_check_started, " + "cable_check_status, " + "expected_state", + [ + (False, None, False, None, None), # First request. + ( + True, + None, + False, + None, + None, + ), # Not first request. Contactor status unknown. + (True, True, False, None, None), # Not first request. Contactor closed. + (True, False, False, None, Terminate), # Contactor close failed. + ( + True, + True, + True, + IsolationLevel.VALID, + PreCharge, + ), # noqa Contactor closed. Isolation response received - Valid. Next stage Precharge. + ( + True, + True, + True, + IsolationLevel.INVALID, + Terminate, + ), # noqa Contactor closed. Isolation response received - Invalid. Terminate. + ( + True, + True, + True, + IsolationLevel.WARNING, + PreCharge, + ), # noqa Contactor closed. Isolation response received - Warning. Next stage Precharge. + ( + True, + True, + True, + IsolationLevel.FAULT, + Terminate, + ), # noqa Contactor closed. Isolation response received - Fault. Terminate session. + ], + ) + async def test_15118_dinspec_dc_cable_check( + self, + cable_check_req_received: bool, + is_contactor_closed: bool, + cable_check_started: bool, + cable_check_status: IsolationLevel, + expected_state: Type[State], + ): + cable_check_req = V2GMessage( + header=MessageHeader(session_id=self.comm_session.session_id), + body=Body( + cable_check_req=CableCheckReq( + dc_ev_status=DCEVStatus( + ev_ready=True, + ev_error_code=DCEVErrorCode.NO_ERROR, + ev_ress_soc=35, + ) + ) + ), + ) + + dc_cable_check = CableCheck(self.comm_session) + dc_cable_check.cable_check_req_was_received = cable_check_req_received + dc_cable_check.contactors_closed_for_cable_check = is_contactor_closed + dc_cable_check.cable_check_started = cable_check_started + contactor_status = AsyncMock(return_value=is_contactor_closed) + self.comm_session.evse_controller.is_contactor_closed = contactor_status + cable_check_status = AsyncMock(return_value=cable_check_status) + self.comm_session.evse_controller.get_cable_check_status = cable_check_status + await dc_cable_check.process_message(message=cable_check_req) + assert dc_cable_check.next_state is expected_state diff --git a/tests/iso15118_2/secc/states/test_iso15118_2_states.py b/tests/iso15118_2/secc/states/test_iso15118_2_states.py index ff63c6e5..ffc65000 100644 --- a/tests/iso15118_2/secc/states/test_iso15118_2_states.py +++ b/tests/iso15118_2/secc/states/test_iso15118_2_states.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import List, cast +from typing import List, Type, cast from unittest.mock import AsyncMock, Mock, patch import pytest @@ -16,11 +16,13 @@ from iso15118.secc.controller.interface import AuthorizationResponse from iso15118.secc.states.iso15118_2_states import ( Authorization, + CableCheck, ChargeParameterDiscovery, ChargingStatus, CurrentDemand, PaymentDetails, PowerDelivery, + PreCharge, ServiceDetail, ServiceDiscovery, SessionSetup, @@ -36,6 +38,7 @@ AuthorizationTokenType, EnergyTransferModeEnum, EVSEProcessing, + IsolationLevel, Protocol, ) from iso15118.shared.messages.iso15118_2.body import ResponseCode @@ -53,8 +56,9 @@ from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 from iso15118.shared.security import get_random_bytes from iso15118.shared.settings import load_shared_settings -from iso15118.shared.states import Pause +from iso15118.shared.states import Pause, State from tests.iso15118_2.secc.states.test_messages import ( + get_cable_check_req, get_charge_parameter_discovery_req_message_departure_time_one_hour, get_charge_parameter_discovery_req_message_no_departure_time, get_dummy_charging_status_req, @@ -1103,3 +1107,77 @@ async def test_sales_tariff_in_free_charging_schedules(self, free_charging_servi if free_charging_service else not None ) + + @pytest.mark.parametrize( + "cable_check_req_received, " + "is_contactor_closed, " + "cable_check_started, " + "cable_check_status, " + "expected_state", + [ + (False, None, False, None, None), # First request. + ( + True, + None, + False, + None, + None, + ), # Not first request. Contactor status unknown. + (True, True, False, None, None), # Not first request. Contactor closed. + (True, False, False, None, Terminate), # Contactor close failed. + ( + True, + True, + True, + IsolationLevel.VALID, + PreCharge, + ), # noqa Contactor closed. Isolation response received - Valid. Next stage Precharge. + ( + True, + True, + True, + IsolationLevel.INVALID, + Terminate, + ), # noqa Contactor closed. Isolation response received - Invalid. Terminate. + ( + True, + True, + True, + IsolationLevel.WARNING, + PreCharge, + ), # noqa Contactor closed. Isolation response received - Warning. Next stage Precharge. + ( + True, + True, + True, + IsolationLevel.FAULT, + Terminate, + ), # noqa Contactor closed. Isolation response received - Fault. Terminate session. + ( + True, + True, + True, + IsolationLevel.NO_IMD, + Terminate, + ), + # noqa Contactor closed. Isolation response received - Fault. Terminate session. + ], + ) + async def test_15118_2_dc_cable_check( + self, + cable_check_req_received: bool, + is_contactor_closed: bool, + cable_check_started: bool, + cable_check_status: IsolationLevel, + expected_state: Type[State], + ): + dc_cable_check = CableCheck(self.comm_session) + dc_cable_check.cable_check_req_was_received = cable_check_req_received + dc_cable_check.contactors_closed_for_cable_check = is_contactor_closed + dc_cable_check.cable_check_started = cable_check_started + contactor_status = AsyncMock(return_value=is_contactor_closed) + self.comm_session.evse_controller.is_contactor_closed = contactor_status + cable_check_status = AsyncMock(return_value=cable_check_status) + self.comm_session.evse_controller.get_cable_check_status = cable_check_status + await dc_cable_check.process_message(message=get_cable_check_req()) + assert dc_cable_check.next_state is expected_state diff --git a/tests/iso15118_2/secc/states/test_messages.py b/tests/iso15118_2/secc/states/test_messages.py index e179cc32..2e258f08 100644 --- a/tests/iso15118_2/secc/states/test_messages.py +++ b/tests/iso15118_2/secc/states/test_messages.py @@ -12,6 +12,7 @@ from iso15118.shared.messages.iso15118_2.body import ( AuthorizationReq, Body, + CableCheckReq, ChargeParameterDiscoveryReq, ChargingStatusReq, PaymentDetailsReq, @@ -590,3 +591,16 @@ def get_v2g_message_charge_parameter_discovery_req( ) ), ) + + +def get_cable_check_req() -> V2GMessage: + return V2GMessage( + header=MessageHeader(session_id=MOCK_SESSION_ID), + body=Body( + cable_check_req=CableCheckReq( + dc_ev_status=DCEVStatus( + ev_ready=True, ev_error_code=DCEVErrorCode.NO_ERROR, ev_ress_soc=35 + ) + ) + ), + ) diff --git a/tests/iso15118_20/secc/test_iso15118_20_dc_states.py b/tests/iso15118_20/secc/test_iso15118_20_dc_states.py index a0bf55e9..b2ef0ee9 100644 --- a/tests/iso15118_20/secc/test_iso15118_20_dc_states.py +++ b/tests/iso15118_20/secc/test_iso15118_20_dc_states.py @@ -209,25 +209,62 @@ async def test_15118_20_schedule_exchange_res( @pytest.mark.parametrize( "cable_check_req_received, " "is_contactor_closed, " + "cable_check_started, " "cable_check_status, " "expected_state", [ - (False, False, IsolationLevel.VALID, Terminate), - (False, True, None, None), - (False, True, IsolationLevel.VALID, DCPreCharge), - (True, True, None, None), - (True, True, IsolationLevel.VALID, DCPreCharge), + (False, None, False, None, None), # First request. + ( + True, + None, + False, + None, + None, + ), # Not first request. Contactor status unknown. + (True, True, False, None, None), # Not first request. Contactor closed. + (True, False, False, None, Terminate), # Contactor close failed. + ( + True, + True, + True, + IsolationLevel.VALID, + DCPreCharge, + ), # noqa Contactor closed. Isolation response received - Valid. Next stage Precharge. + ( + True, + True, + True, + IsolationLevel.INVALID, + Terminate, + ), # noqa Contactor closed. Isolation response received - Invalid. Terminate. + ( + True, + True, + True, + IsolationLevel.WARNING, + DCPreCharge, + ), # noqa Contactor closed. Isolation response received - Warning. Next stage Precharge. + ( + True, + True, + True, + IsolationLevel.FAULT, + Terminate, + ), # noqa Contactor closed. Isolation response received - Fault. Terminate session. ], ) async def test_15118_20_dc_cable_check( self, cable_check_req_received: bool, is_contactor_closed: bool, + cable_check_started: bool, cable_check_status: IsolationLevel, expected_state: Type[State], ): dc_cable_check = DCCableCheck(self.comm_session) dc_cable_check.cable_check_req_was_received = cable_check_req_received + dc_cable_check.contactors_closed_for_cable_check = is_contactor_closed + dc_cable_check.cable_check_started = cable_check_started contactor_status = AsyncMock(return_value=is_contactor_closed) self.comm_session.evse_controller.is_contactor_closed = contactor_status cable_check_status = AsyncMock(return_value=cable_check_status)