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 353ef901..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,20 @@ 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: # 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, @@ -479,9 +490,36 @@ async def process_message( ) return - # First CableCheckReq received. Start cable check. - await self.comm_session.evse_controller.start_cable_check() - self.cable_check_req_was_received = True + 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 @@ -494,24 +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, - ]: - 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_2_states.py b/iso15118/secc/states/iso15118_2_states.py index 6f7a84e2..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,11 +2216,20 @@ async def process_message( ) return + next_state = None + 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, @@ -2226,41 +2237,42 @@ async def process_message( ) return - # First CableCheckReq received. Start cable check. - await self.comm_session.evse_controller.start_cable_check() - self.cable_check_req_was_received = True + 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 ) - 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, - ]: - self.stop_state_machine( - f"Isolation Failure: {isolation_level}", - message, - ResponseCode.FAILED, - ) - return - cable_check_res = CableCheckRes( response_code=ResponseCode.OK, dc_evse_status=await self.comm_session.evse_controller.get_dc_evse_status(), 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 + ) + ) + ), + )