diff --git a/iso15118/secc/states/secc_state.py b/iso15118/secc/states/secc_state.py index 15645b03..3b982814 100644 --- a/iso15118/secc/states/secc_state.py +++ b/iso15118/secc/states/secc_state.py @@ -294,10 +294,13 @@ def stop_state_machine( V2GMessageV2, V2GMessageV20, V2GMessageDINSPEC, + None, ], response_code: Union[ ResponseCodeSAP, ResponseCodeV2, ResponseCodeV20, ResponseCodeDINSPEC ], + message_body_type: Optional[type] = None, + namespace: Optional[Namespace] = None, ): """ In case the processing of a message from the EVCC fails, the SECC needs @@ -317,31 +320,44 @@ def stop_state_machine( if isinstance(faulty_request, V2GMessageV2): msg_type = get_msg_type(str(faulty_request)) + msg_namespace = Namespace.ISO_V2_MSG_DEF + elif isinstance(faulty_request, V2GMessageDINSPEC): + msg_type = get_msg_type_dinspec(str(faulty_request)) + msg_namespace = Namespace.DIN_MSG_DEF + elif isinstance(faulty_request, V2GMessageV20): + msg_type = type(faulty_request) + msg_namespace = Namespace.ISO_V20_BASE + elif isinstance(faulty_request, SupportedAppProtocolReq): + msg_namespace = Namespace.SAP + msg_type = faulty_request + else: + msg_type = message_body_type + msg_namespace = namespace + + if msg_namespace == Namespace.ISO_V2_MSG_DEF: error_res = self.comm_session.failed_responses_isov2.get(msg_type) error_res.response_code = response_code self.create_next_message(Terminate, error_res, 0, Namespace.ISO_V2_MSG_DEF) - elif isinstance(faulty_request, V2GMessageDINSPEC): - msg_type = get_msg_type_dinspec(str(faulty_request)) + elif msg_namespace == Namespace.DIN_MSG_DEF: error_res = self.comm_session.failed_responses_din_spec.get(msg_type) error_res.response_code = response_code self.create_next_message(Terminate, error_res, 0, Namespace.DIN_MSG_DEF) # Here we could have been more specific and check if it is a V2GRequestV20, # but to be consistent with the other if clauses and since there is no negative # consequences in the behavior of the code, we check if it is a V2GMessageV20 - elif isinstance(faulty_request, V2GMessageV20): + elif msg_namespace.startswith(Namespace.ISO_V20_BASE): ( error_res, namespace, payload_type, - ) = self.comm_session.failed_responses_isov20.get(type(faulty_request)) + ) = self.comm_session.failed_responses_isov20.get(msg_type) # As the Header in the case of -20 is part of the -20 message payload, - # we need to set the session id of the the current session to it + # we need to set the session id of the current session to it error_res.header.session_id = self.comm_session.session_id error_res.response_code = response_code self.create_next_message(Terminate, error_res, 0, namespace, payload_type) - elif isinstance(faulty_request, SupportedAppProtocolReq): + elif msg_namespace == Namespace.SAP: error_res = SupportedAppProtocolRes(response_code=response_code) - self.create_next_message(Terminate, error_res, 0, Namespace.SAP) else: # Should actually never happen diff --git a/iso15118/shared/comm_session.py b/iso15118/shared/comm_session.py index 4e8e2e98..84714834 100644 --- a/iso15118/shared/comm_session.py +++ b/iso15118/shared/comm_session.py @@ -19,6 +19,7 @@ FaultyStateImplementationError, InvalidV2GTPMessageError, MessageProcessingError, + V2GMessageValidationError, ) from iso15118.shared.exi_codec import EXI from iso15118.shared.messages.app_protocol import ( @@ -204,6 +205,15 @@ async def process_message(self, message: bytes): f"{self.get_exi_ns(v2gtp_msg.payload_type).value}" ) + except V2GMessageValidationError as exc: + self.comm_session.current_state.stop_state_machine( + exc.reason, + None, + exc.response_code, + exc.message, + self.get_exi_ns(v2gtp_msg.payload_type), + ) + return except EXIDecodingError as exc: logger.exception(f"{exc}") raise exc diff --git a/iso15118/shared/exceptions.py b/iso15118/shared/exceptions.py index 1cc9f64e..7631967b 100644 --- a/iso15118/shared/exceptions.py +++ b/iso15118/shared/exceptions.py @@ -1,5 +1,7 @@ from typing import Any +from iso15118.shared.messages.iso15118_2.datatypes import ResponseCode + class InvalidInterfaceError(Exception): """ @@ -235,3 +237,13 @@ def __init__(self): self, "No OCSP server entry in Authority Information Access extension field.", ) + + +class V2GMessageValidationError(Exception): + """Is thrown if message validation is failed""" + + def __init__(self, reason: str, response_code: ResponseCode, message: Any): + Exception.__init__(self) + self.reason = reason + self.response_code = response_code + self.message = message diff --git a/iso15118/shared/exi_codec.py b/iso15118/shared/exi_codec.py index 40ac8e67..4b6cfabb 100644 --- a/iso15118/shared/exi_codec.py +++ b/iso15118/shared/exi_codec.py @@ -6,7 +6,11 @@ from pydantic import ValidationError -from iso15118.shared.exceptions import EXIDecodingError, EXIEncodingError +from iso15118.shared.exceptions import ( + EXIDecodingError, + EXIEncodingError, + V2GMessageValidationError, +) from iso15118.shared.exificient_exi_codec import ExificientEXICodec from iso15118.shared.iexi_codec import IEXICodec from iso15118.shared.messages import BaseModel @@ -14,8 +18,11 @@ SupportedAppProtocolReq, SupportedAppProtocolRes, ) +from iso15118.shared.messages.din_spec.body import get_msg_type as get_msg_type_dinspec from iso15118.shared.messages.din_spec.msgdef import V2GMessage as V2GMessageDINSPEC from iso15118.shared.messages.enums import Namespace +from iso15118.shared.messages.iso15118_2.body import get_msg_type +from iso15118.shared.messages.iso15118_2.datatypes import ResponseCode from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 from iso15118.shared.messages.iso15118_20.ac import ( ACChargeLoopReq, @@ -379,10 +386,28 @@ def from_exi( raise EXIDecodingError("Can't identify protocol to use for decoding") except ValidationError as exc: - raise EXIDecodingError( - f"Error parsing the decoded EXI into a Pydantic class: {exc}. " - f"\n\nDecoded dict: {decoded_dict}" + if namespace == Namespace.ISO_V2_MSG_DEF: + msg_name = next(iter(decoded_dict["V2G_Message"]["Body"])) + msg_type = get_msg_type(msg_name) + elif namespace == Namespace.DIN_MSG_DEF: + msg_name = next(iter(decoded_dict["V2G_Message"]["Body"])) + msg_type = get_msg_type_dinspec(msg_name) + elif namespace.startswith(Namespace.ISO_V20_BASE): + msg_type = msg_class + elif namespace == Namespace.SAP: + if "supportedAppProtocolReq" in decoded_dict: + msg_type = SupportedAppProtocolReq + elif "supportedAppProtocolRes" in decoded_dict: + msg_type = SupportedAppProtocolRes + + raise V2GMessageValidationError( + f"Validation error: {exc}. \n\nDecoded dict: " f"{decoded_dict}", + ResponseCode.FAILED, + msg_type, ) from exc + + except V2GMessageValidationError as exc: + raise exc except EXIDecodingError as exc: raise EXIDecodingError( f"EXI decoding error: {exc}. \n\nDecoded dict: " f"{decoded_dict}" diff --git a/iso15118/shared/messages/din_spec/body.py b/iso15118/shared/messages/din_spec/body.py index 245f33ba..3f95132c 100644 --- a/iso15118/shared/messages/din_spec/body.py +++ b/iso15118/shared/messages/din_spec/body.py @@ -4,6 +4,7 @@ from pydantic import Field, root_validator, validator +from iso15118.shared.exceptions import V2GMessageValidationError from iso15118.shared.messages import BaseModel from iso15118.shared.messages.datatypes import ( DCEVSEChargeParameter, @@ -234,11 +235,6 @@ def validate_requested_energy_mode(cls, values): Pydantic validators are "class methods", see https://pydantic-docs.helpmanual.io/usage/validators/ - TODO We need to actually send FAILED_WrongChargeParameter or - FAILED_WrongEnergyTransferMode if the wrong parameter set is - provided, one or multiple parameters can not be interpreted - (see [V2G2-477]). Need to check how to not just bury - that information in a pydantic validation error. """ # pylint: disable=no-self-argument # pylint: disable=no-self-use @@ -249,15 +245,20 @@ def validate_requested_energy_mode(cls, values): values.get("dc_ev_charge_parameter"), ) if requested_energy_mode not in ("DC_extended", "DC_core"): - raise ValueError( - f"Wrong energy transfer mode transfer mode {requested_energy_mode}" + raise V2GMessageValidationError( + f"[V2G2-476] Wrong energy transfer mode transfer mode " + f"{requested_energy_mode}", + ResponseCode.FAILED_WRONG_ENERGY_TRANSFER_MODE, + cls, ) if ("AC_" in requested_energy_mode and dc_params) or ( "DC_" in requested_energy_mode and ac_params ): - raise ValueError( - "Wrong charge parameters for requested energy " - f"transfer mode {requested_energy_mode}" + raise V2GMessageValidationError( + "[V2G2-477] Wrong charge parameters for requested energy " + f"transfer mode {requested_energy_mode}", + ResponseCode.FAILED_WRONG_CHARGE_PARAMETER, + cls, ) return values diff --git a/iso15118/shared/messages/iso15118_2/body.py b/iso15118/shared/messages/iso15118_2/body.py index e775855b..92996dcc 100644 --- a/iso15118/shared/messages/iso15118_2/body.py +++ b/iso15118/shared/messages/iso15118_2/body.py @@ -15,6 +15,7 @@ from pydantic import Field, root_validator, validator +from iso15118.shared.exceptions import V2GMessageValidationError from iso15118.shared.messages import BaseModel from iso15118.shared.messages.datatypes import ( DCEVSEChargeParameter, @@ -251,15 +252,9 @@ def requested_energy_mode_must_match_charge_parameter(cls, values): Pydantic validators are "class methods", see https://pydantic-docs.helpmanual.io/usage/validators/ - TODO We need to actually send FAILED_WrongChargeParameter or - FAILED_WrongEnergyTransferMode if the wrong parameter set is - provided, one or multiple parameters can not be interpreted - (see [V2G2-477]). Need to check how to not just bury - that information in a pydantic validation error. """ # pylint: disable=no-self-argument # pylint: disable=no-self-use - requested_energy_mode, ac_params, dc_params = ( values.get("requested_energy_mode"), values.get("ac_ev_charge_parameter"), @@ -268,10 +263,13 @@ def requested_energy_mode_must_match_charge_parameter(cls, values): if ("AC_" in requested_energy_mode and dc_params) or ( "DC_" in requested_energy_mode and ac_params ): - raise ValueError( - "Wrong charge parameters for requested energy " - f"transfer mode {requested_energy_mode}" + raise V2GMessageValidationError( + "[V2G2-477] Wrong charge parameters for requested energy " + f"transfer mode {requested_energy_mode}", + ResponseCode.FAILED_WRONG_CHARGE_PARAMETER, + cls, ) + return values diff --git a/iso15118/shared/messages/iso15118_2/msgdef.py b/iso15118/shared/messages/iso15118_2/msgdef.py index 9ddde40e..22dbed7b 100644 --- a/iso15118/shared/messages/iso15118_2/msgdef.py +++ b/iso15118/shared/messages/iso15118_2/msgdef.py @@ -12,7 +12,6 @@ (or class) that matches the definitions in the XSD schema, including the XSD element names by using the 'alias' attribute. """ - from pydantic import Field from iso15118.shared.messages import BaseModel diff --git a/iso15118/shared/messages/iso15118_20/ac.py b/iso15118/shared/messages/iso15118_20/ac.py index cd34e42a..e7eea26b 100644 --- a/iso15118/shared/messages/iso15118_20/ac.py +++ b/iso15118/shared/messages/iso15118_20/ac.py @@ -13,6 +13,7 @@ from pydantic import Field, root_validator +from iso15118.shared.exceptions import V2GMessageValidationError from iso15118.shared.messages import BaseModel from iso15118.shared.messages.iso15118_20.common_types import ( ChargeLoopReq, @@ -22,6 +23,7 @@ DynamicChargeLoopReqParams, DynamicChargeLoopResParams, RationalNumber, + ResponseCode, ScheduledChargeLoopReqParams, ScheduledChargeLoopResParams, ) @@ -335,17 +337,24 @@ def either_ac_or_ac_bpt_params(cls, values): """ # pylint: disable=no-self-argument # pylint: disable=no-self-use - if one_field_must_be_set( - [ - "ac_params", - "AC_CPDReqEnergyTransferMode", - "bpt_ac_params", - "BPT_AC_CPDReqEnergyTransferMode", - ], - values, - True, - ): - return values + try: + if one_field_must_be_set( + [ + "ac_params", + "AC_CPDReqEnergyTransferMode", + "bpt_ac_params", + "BPT_AC_CPDReqEnergyTransferMode", + ], + values, + True, + ): + return values + except ValueError as exc: + raise V2GMessageValidationError( + str(exc), + ResponseCode.FAILED_WRONG_CHARGE_PARAMETER, + ChargeParameterDiscoveryReq, + ) def __str__(self): # The XSD-conform name diff --git a/iso15118/shared/messages/iso15118_20/common_types.py b/iso15118/shared/messages/iso15118_20/common_types.py index cc672dd6..fbf30c1b 100644 --- a/iso15118/shared/messages/iso15118_20/common_types.py +++ b/iso15118/shared/messages/iso15118_20/common_types.py @@ -73,7 +73,7 @@ class V2GMessage(BaseModel, ABC): """See section 8.3 in ISO 15118-20 This class model follows the schemas, where the V2GMessage type is defined, in the V2G_CI_CommonTypes.xsd schema. - This type is the base of all messages and contains the the Header + This type is the base of all messages and contains the Header This is a tiny but quite important difference in respect to ISO 15118-2 payload structure, where the header is not included within each Request and Response message diff --git a/tests/secc/messages/15118_2_invalid_messages.py b/tests/secc/messages/15118_2_invalid_messages.py new file mode 100644 index 00000000..1b6bf853 --- /dev/null +++ b/tests/secc/messages/15118_2_invalid_messages.py @@ -0,0 +1,49 @@ +import json +from dataclasses import dataclass + +from iso15118.shared.exceptions import V2GMessageValidationError +from iso15118.shared.messages.iso15118_2.body import Body, ChargeParameterDiscoveryReq +from iso15118.shared.messages.iso15118_2.datatypes import ResponseCode +from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage + + +@dataclass +class InvalidV2GMessage: + msg: str + msg_body: Body + response_code: ResponseCode + + +invalid_v2g_2_messages = [ + ( + InvalidV2GMessage( + ( + # [V2G2-477] + # Parameters are not compatible with RequestedEnergyTransferMode + '{"V2G_Message":{"Header":{"SessionID":"82DBA3A44ED6E5B9"},"Body":' + '{"ChargeParameterDiscoveryReq":{"MaxEntriesSAScheduleTuple":16,' + '"RequestedEnergyTransferMode":"AC_three_phase_core",' + '"DC_EVChargeParameter":' + '{"DepartureTime":0,"DC_EVStatus":{"EVReady":false,"EVErrorCode":' + '"NO_ERROR","EVRESSSOC":20},"EVMaximumCurrentLimit":{"Multiplier":' + '1,"Unit":"A","Value":8},"EVMaximumPowerLimit":{"Multiplier":3,' + '"Unit":"W","Value":29},"EVMaximumVoltageLimit":{"Multiplier":2,' + '"Unit":"V","Value":5},"EVEnergyCapacity":{"Multiplier":3,"Unit":' + '"Wh","Value":200},"EVEnergyRequest":{"Multiplier":3,"Unit":"Wh",' + '"Value":160},"FullSOC":99,"BulkSOC":80}}}}}' + ), + ChargeParameterDiscoveryReq, + ResponseCode.FAILED_WRONG_CHARGE_PARAMETER, + ) + ), +] + + +def test_invalid_v2g_2_messages(): + for message in invalid_v2g_2_messages: + try: + invalid_msg = json.loads(message.msg) + V2GMessage.parse_obj(invalid_msg["V2G_Message"]) + except V2GMessageValidationError as exc: + assert exc.message is message.msg_body + assert exc.response_code is message.response_code