diff --git a/Makefile b/Makefile index 1d3253ec..c7d94f8e 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ run-secc: mypy: - mypy --config-file ../mypy.ini iso15118 tests + mypy --config-file mypy.ini iso15118 tests reformat: isort iso15118 tests && black --line-length=88 iso15118 tests @@ -88,7 +88,7 @@ black: black --check --diff --line-length=88 iso15118 tests flake8: - flake8 --config ../.flake8 iso15118 tests + flake8 --config .flake8 iso15118 tests code-quality: reformat mypy black flake8 diff --git a/iso15118/evcc/controller/interface.py b/iso15118/evcc/controller/interface.py index 48e58217..a7121d44 100644 --- a/iso15118/evcc/controller/interface.py +++ b/iso15118/evcc/controller/interface.py @@ -4,23 +4,22 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple from iso15118.shared.messages.enums import Protocol from iso15118.shared.messages.iso15118_2.datatypes import ( ACEVChargeParameter, ChargeProgress, + ChargingProfile, DCEVChargeParameter, - EnergyTransferMode, EnergyTransferModeEnum, - ProfileEntry, - SAScheduleTuple, + SAScheduleTupleEntry, ) from iso15118.shared.messages.iso15118_20.ac import ( ACChargeParameterDiscoveryReqParams, BPTACChargeParameterDiscoveryReqParams, ) -from iso15118.shared.messages.iso15118_20.common_messages import EMAID +from iso15118.shared.messages.iso15118_20.common_messages import EMAIDList @dataclass @@ -127,8 +126,8 @@ def is_cert_install_needed(self) -> bool: @abstractmethod def process_sa_schedules( - self, sa_schedules: List[SAScheduleTuple] - ) -> Tuple[ChargeProgress, int, List[ProfileEntry]]: + self, sa_schedules: List[SAScheduleTupleEntry] + ) -> Tuple[ChargeProgress, int, ChargingProfile]: """ Processes the SAScheduleList provided with the ChargeParameterDiscoveryRes to decide which of the offered schedules to choose and whether or not to @@ -184,7 +183,7 @@ def store_contract_cert_and_priv_key(self, contract_cert: bytes, priv_key: bytes raise NotImplementedError @abstractmethod - def get_prioritised_emaids(self) -> Optional[List[EMAID]]: + def get_prioritised_emaids(self) -> Optional[EMAIDList]: """ Indicates the list of EMAIDs (E-Mobility Account IDs) referencing contract certificates that shall be installed into the EV. The EMAIDs are given in diff --git a/iso15118/evcc/controller/simulator.py b/iso15118/evcc/controller/simulator.py index 1a6cbdea..5bf54345 100644 --- a/iso15118/evcc/controller/simulator.py +++ b/iso15118/evcc/controller/simulator.py @@ -12,22 +12,22 @@ from iso15118.shared.messages.iso15118_2.datatypes import ( ACEVChargeParameter, ChargeProgress, + ChargingProfile, EnergyTransferModeEnum, - ProfileEntry, ProfileEntryDetails, PVEAmount, PVEVMaxCurrent, PVEVMaxVoltage, PVEVMinCurrent, PVPMax, - SAScheduleTuple, + SAScheduleTupleEntry, UnitSymbol, ) from iso15118.shared.messages.iso15118_20.ac import ( ACChargeParameterDiscoveryReqParams, BPTACChargeParameterDiscoveryReqParams, ) -from iso15118.shared.messages.iso15118_20.common_messages import EMAID +from iso15118.shared.messages.iso15118_20.common_messages import EMAIDList from iso15118.shared.messages.iso15118_20.common_types import RationalNumber from iso15118.shared.network import get_nic_mac_address @@ -114,23 +114,22 @@ def is_cert_install_needed(self) -> bool: return True def process_sa_schedules( - self, sa_schedules: List[SAScheduleTuple] - ) -> Tuple[ChargeProgress, int, List[ProfileEntry]]: + self, sa_schedules: List[SAScheduleTupleEntry] + ) -> Tuple[ChargeProgress, int, ChargingProfile]: """Overrides EVControllerInterface.process_sa_schedules().""" schedule = sa_schedules.pop() - profile_entry_list: List[ProfileEntry] = [] + profile_entry_list: List[ProfileEntryDetails] = [] # The charging schedule coming from the SECC is called 'schedule', the # pendant coming from the EVCC (after having processed the offered # schedule(s)) is called 'profile'. Therefore, we use the prefix # 'schedule_' for data from the SECC, and 'profile_' for data from the EVCC. - for p_max_schedule_entry in schedule.tuple.p_max_schedule: - schedule_entry_details = p_max_schedule_entry.entry_details + for schedule_entry_details in schedule.p_max_schedule.entry_details: profile_entry_details = ProfileEntryDetails( start=schedule_entry_details.time_interval.start, max_power=schedule_entry_details.p_max, ) - profile_entry_list.append(ProfileEntry(entry_details=profile_entry_details)) + profile_entry_list.append(profile_entry_details) # The last PMaxSchedule element has an optional 'duration' field. if # 'duration' is present, then there'll be no more PMaxSchedule element @@ -145,17 +144,15 @@ def process_sa_schedules( ), max_power=zero_power, ) - profile_entry_list.append( - ProfileEntry(entry_details=last_profile_entry_details) - ) + profile_entry_list.append(last_profile_entry_details) # TODO If a SalesTariff is present and digitally signed (and TLS is used), # verify each sales tariff with the mobility operator sub 2 certificate return ( ChargeProgress.START, - schedule.tuple.sa_schedule_tuple_id, - profile_entry_list, + schedule.sa_schedule_tuple_id, + ChargingProfile(profile_entries=profile_entry_list), ) def continue_charging(self) -> bool: @@ -175,5 +172,5 @@ def store_contract_cert_and_priv_key(self, contract_cert: bytes, priv_key: bytes # TODO Need to store the contract cert and private key pass - def get_prioritised_emaids(self) -> Optional[List[EMAID]]: + def get_prioritised_emaids(self) -> Optional[EMAIDList]: return None diff --git a/iso15118/evcc/evcc_settings.py b/iso15118/evcc/evcc_settings.py index 94c5cbee..25578214 100644 --- a/iso15118/evcc/evcc_settings.py +++ b/iso15118/evcc/evcc_settings.py @@ -50,8 +50,8 @@ def load_envs(self, env_path: Optional[str] = None) -> None: self.log_level = env.str("LOG_LEVEL", default="INFO") - # Choose the EVController implementation. Must be the class name of the controller - # that implements the EVControllerInterface + # Choose the EVController implementation. Must be the class name of the + # controller that implements the EVControllerInterface self.ev_controller = EVControllerInterface if env.bool("EVCC_CONTROLLER_SIM", default=False): self.ev_controller = SimEVController @@ -70,13 +70,15 @@ def load_envs(self, env_path: Optional[str] = None) -> None: "MAX_CONTRACT_CERTS", default=3, validate=Range(min=1, max=65535) ) - # Indicates the security level (either TCP (unencrypted) or TLS (encrypted)) the EVCC - # shall send in the SDP request + # Indicates the security level (either TCP (unencrypted) or TLS (encrypted)) + # the EVCC shall send in the SDP request self.use_tls = env.bool("EVCC_USE_TLS", default=True) - # Indicates whether or not the EVCC should always enforce a TLS-secured communication - # session. If True, the EVCC will only continue setting up a communication session if - # the SECC's SDP response has the Security field set to the enum value Security.TLS. + # Indicates whether or not the EVCC should always enforce a TLS-secured + # communication session. + # If True, the EVCC will only continue setting up a communication session if + # the SECC's SDP response has the Security field set + # to the enum value Security.TLS. # If the USE_TLS setting is set to False and ENFORCE_TLS is set to True, then # ENFORCE_TLS overrules USE_TLS. self.enforce_tls = env.bool("EVCC_ENFORCE_TLS", default=False) diff --git a/iso15118/evcc/states/evcc_state.py b/iso15118/evcc/states/evcc_state.py index a777879b..9b3b20d5 100644 --- a/iso15118/evcc/states/evcc_state.py +++ b/iso15118/evcc/states/evcc_state.py @@ -6,22 +6,15 @@ from typing import Optional, Type, TypeVar, Union from iso15118.evcc.comm_session_handler import EVCCCommunicationSession -from iso15118.shared.exceptions import FaultyStateImplementationError from iso15118.shared.messages.app_protocol import ( SupportedAppProtocolReq, SupportedAppProtocolRes, ) from iso15118.shared.messages.iso15118_2.body import BodyBase, Response -from iso15118.shared.messages.iso15118_2.body import ( - SessionSetupReq as SessionSetupReqV2, -) from iso15118.shared.messages.iso15118_2.body import ( SessionSetupRes as SessionSetupResV2, ) from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 -from iso15118.shared.messages.iso15118_20.common_messages import ( - SessionSetupReq as SessionSetupReqV20, -) from iso15118.shared.messages.iso15118_20.common_messages import ( SessionSetupRes as SessionSetupResV20, ) diff --git a/iso15118/evcc/states/iso15118_20_states.py b/iso15118/evcc/states/iso15118_20_states.py index 2d74c0c5..111444e8 100644 --- a/iso15118/evcc/states/iso15118_20_states.py +++ b/iso15118/evcc/states/iso15118_20_states.py @@ -165,8 +165,8 @@ def process_message( ) ) ], - max_contract_cert_chains=self.comm_session.config.max_contract_certs, - prioritized_emaids=self.comm_session.ev_controller.get_prioritised_emaids(), + max_contract_cert_chains=self.comm_session.config.max_contract_certs, # noqa: E501 + prioritized_emaids=self.comm_session.ev_controller.get_prioritised_emaids(), # noqa: E501 ) self.create_next_message( @@ -178,7 +178,7 @@ def process_message( except PrivateKeyReadError as exc: self.stop_state_machine( "Can't read private key necessary to sign " - f"CertificateInstallationReq: {exc.error}" + f"CertificateInstallationReq: {exc}" ) return @@ -213,7 +213,7 @@ def process_message( except PrivateKeyReadError as exc: self.stop_state_machine( "Can't read private key necessary to sign " - f"AuthorizationReq: {exc.error}" + f"AuthorizationReq: {exc}" ) return else: diff --git a/iso15118/evcc/states/iso15118_2_states.py b/iso15118/evcc/states/iso15118_2_states.py index a0aff9bc..8617a147 100644 --- a/iso15118/evcc/states/iso15118_2_states.py +++ b/iso15118/evcc/states/iso15118_2_states.py @@ -47,13 +47,13 @@ ) from iso15118.shared.messages.iso15118_2.datatypes import ( ACEVSEStatus, - AuthOptions, ChargeProgress, + ChargeService, ChargingSession, - EnergyTransferMode, + EnergyTransferModeEnum, EVSENotification, EVSEProcessing, - RootCertificateID, + RootCertificateIDList, SelectedService, SelectedServiceList, ServiceCategory, @@ -158,17 +158,16 @@ def process_message( self.stop_state_machine("ChargeService not offered") return - self.select_auth_mode(service_discovery_res.auth_option_list) + self.select_auth_mode(service_discovery_res.auth_option_list.auth_options) self.select_services(service_discovery_res) self.select_energy_transfer_mode() - offered_energy_modes = ( - service_discovery_res.charge_service.supported_energy_transfer_mode - ) - if ( - EnergyTransferMode(value=self.comm_session.selected_energy_mode) - not in offered_energy_modes - ): + charge_service: ChargeService = service_discovery_res.charge_service + offered_energy_modes: List[ + EnergyTransferModeEnum + ] = charge_service.supported_energy_transfer_mode.energy_modes + + if self.comm_session.selected_energy_mode not in offered_energy_modes: self.stop_state_machine( f"Offered energy transfer modes " f"{offered_energy_modes} not compatible with " @@ -223,7 +222,7 @@ def select_energy_transfer_mode(self): self.comm_session.ev_controller.get_energy_transfer_mode() ) - def select_auth_mode(self, auth_option_list: List[AuthOptions]): + def select_auth_mode(self, auth_option_list: List[AuthEnum]): """ Check if an authorization mode (aka payment option in ISO 15118-2) was saved from a previously paused communication session and reuse for @@ -240,15 +239,12 @@ def select_auth_mode(self, auth_option_list: List[AuthOptions]): ) evcc_settings.RESUME_SELECTED_AUTH_OPTION = None else: - # Chose Plug & Charge (pnc) or External Identification Means (eim) + # Choose Plug & Charge (pnc) or External Identification Means (eim) # as the selected authorization option. The car manufacturer might # have a mechanism to determine a user-defined or default # authorization option. This implementation favors pnc, but # feel free to change if need be. - if ( - AuthOptions(value=AuthEnum.PNC_V2) in auth_option_list - and self.comm_session.is_tls - ): + if AuthEnum.PNC_V2 in auth_option_list and self.comm_session.is_tls: self.comm_session.selected_auth_option = AuthEnum.PNC_V2 else: self.comm_session.selected_auth_option = AuthEnum.EIM_V2 @@ -272,24 +268,22 @@ def select_services(self, service_discovery_res: ServiceDiscoveryRes): offered_services: str = "" - for service in service_discovery_res.service_list: + for service in service_discovery_res.service_list.services: offered_services += ( "\nService ID: " - f"{service.service_details.service_id}, " + f"{service.service_id}, " "Service name: " - f"{service.service_details.service_name}" + f"{service.service_name}" ) if ( - service.service_details.service_category == ServiceCategory.CERTIFICATE + service.service_category == ServiceCategory.CERTIFICATE and self.comm_session.selected_auth_option and self.comm_session.selected_auth_option == AuthEnum.PNC_V2 and self.comm_session.ev_controller.is_cert_install_needed() ): # Make sure to send a ServiceDetailReq for the # Certificate service - self.comm_session.service_details_to_request.append( - service.service_details.service_id - ) + self.comm_session.service_details_to_request.append(service.service_id) # TODO We should actually first ask for the ServiceDetails and # based on the service parameter list make absolutely sure @@ -394,13 +388,13 @@ def process_message( cert_install_req = CertificateInstallationReq( id="id1", oem_provisioning_cert=load_cert(CertPath.OEM_LEAF_DER), - root_cert_ids=[ - RootCertificateID( - x509_issuer_serial=X509IssuerSerial( + list_of_root_cert_ids=RootCertificateIDList( + x509_issuer_serials=[ + X509IssuerSerial( x509_issuer_name=issuer, x509_serial_number=serial ) - ) - ], + ] + ), ) try: @@ -424,7 +418,7 @@ def process_message( except PrivateKeyReadError as exc: self.stop_state_machine( "Can't read private key necessary to sign " - f"CertificateInstallationReq: {exc.error}" + f"CertificateInstallationReq: {exc}" ) return else: @@ -510,10 +504,7 @@ def process_message( ), ], leaf_cert=cert_install_res.cps_cert_chain.certificate, - sub_ca_certs=[ - cert.certificate - for cert in cert_install_res.cps_cert_chain.sub_certificates - ], + sub_ca_certs=cert_install_res.cps_cert_chain.sub_certificates.certificates, root_ca_cert_path=CertPath.V2G_ROOT_DER, ): self.stop_state_machine( @@ -540,12 +531,12 @@ def process_message( self.stop_state_machine( "Can't read private key needed to decrypt " "encrypted private key contained in " - f"CertificateInstallationRes. {exc.error}" + f"CertificateInstallationRes. {exc}" ) return payment_details_req = PaymentDetailsReq( - emaid=EMAID(value=get_cert_cn(load_cert(CertPath.CONTRACT_LEAF_DER))), + emaid=get_cert_cn(load_cert(CertPath.CONTRACT_LEAF_DER)), cert_chain=load_cert_chain( protocol=Protocol.ISO_15118_2, leaf_path=CertPath.CONTRACT_LEAF_DER, @@ -610,7 +601,7 @@ def process_message( ) except PrivateKeyReadError as exc: self.stop_state_machine( - f"Can't read private key to sign AuthorizationReq: " f"{exc.error}" + f"Can't read private key to sign AuthorizationReq: " f"{exc}" ) return @@ -720,7 +711,7 @@ def process_message( schedule_id, charging_profile, ) = self.comm_session.ev_controller.process_sa_schedules( - charge_params_res.sa_schedule_list + charge_params_res.sa_schedule_list.values ) power_delivery_req = PowerDeliveryReq( @@ -899,7 +890,7 @@ def process_message( except PrivateKeyReadError as exc: self.stop_state_machine( "Can't read private key necessary to sign " - f"MeteringReceiptReq: {exc.error}" + f"MeteringReceiptReq: {exc}" ) return elif ac_evse_status.evse_notification == EVSENotification.RE_NEGOTIATION: diff --git a/iso15118/evcc/states/sap_states.py b/iso15118/evcc/states/sap_states.py index 1da74647..154c8d8b 100644 --- a/iso15118/evcc/states/sap_states.py +++ b/iso15118/evcc/states/sap_states.py @@ -108,8 +108,8 @@ def process_message( logger.error( "EVCC sent an invalid protocol namespace in " f"its previous SupportedAppProtocolReq: " - f"{protocol.protocol_ns}. Allowed " - f"namespaces are: {self.comm_session.config.supported_protocols}" + f"{protocol.protocol_ns}. Allowed namespaces are:" + f" {self.comm_session.config.supported_protocols}" ) raise MessageProcessingError("SupportedAppProtocolReq") break diff --git a/iso15118/secc/comm_session_handler.py b/iso15118/secc/comm_session_handler.py index 6029d541..5e35adf0 100644 --- a/iso15118/secc/comm_session_handler.py +++ b/iso15118/secc/comm_session_handler.py @@ -36,7 +36,10 @@ CertificateChain as CertificateChainV2, ) from iso15118.shared.messages.iso15118_2.datatypes import MeterInfo as MeterInfoV2 -from iso15118.shared.messages.iso15118_2.datatypes import SAScheduleTuple, Service +from iso15118.shared.messages.iso15118_2.datatypes import ( + SAScheduleTupleEntry, + ServiceDetails, +) from iso15118.shared.messages.sdp import SDPRequest, Security, create_sdp_response from iso15118.shared.messages.timeouts import Timeouts from iso15118.shared.messages.v2gtp import V2GTPMessage @@ -75,7 +78,7 @@ def __init__( # ISO 15118-2 and with AuthorizationSetupRes in ISO 15118-20 self.offered_auth_options: Optional[List[AuthEnum]] = [] # The value-added services offered with ServiceDiscoveryRes - self.offered_services: List[Service] = [] + self.offered_services: List[ServiceDetails] = [] # The authorization option (called PaymentOption in ISO 15118-2) the # EVCC selected with the PaymentServiceSelectionReq self.selected_auth_option: Optional[AuthEnum] = None @@ -84,7 +87,7 @@ def __init__( self.evcc_id: Union[bytes, str, None] = None # The list of offered charging schedules, sent to the EVCC via the # ChargeParameterDiscoveryRes message - self.offered_schedules: List[SAScheduleTuple] = [] + self.offered_schedules: List[SAScheduleTupleEntry] = [] # Whether or not the SECC received a PowerDeliveryReq with # ChargeProgress set to 'Start' self.charge_progress_started: bool = False diff --git a/iso15118/secc/controller/interface.py b/iso15118/secc/controller/interface.py index 4380038d..bd4d1967 100644 --- a/iso15118/secc/controller/interface.py +++ b/iso15118/secc/controller/interface.py @@ -12,14 +12,13 @@ ACEVSEStatus, DCEVSEChargeParameter, DCEVSEStatus, - EnergyTransferMode, EnergyTransferModeEnum, ) from iso15118.shared.messages.iso15118_2.datatypes import MeterInfo as MeterInfoV2 from iso15118.shared.messages.iso15118_2.datatypes import ( PVEVSEPresentCurrent, PVEVSEPresentVoltage, - SAScheduleTuple, + SAScheduleTupleEntry, ) from iso15118.shared.messages.iso15118_20.common_messages import ProviderID from iso15118.shared.messages.iso15118_20.common_types import MeterInfo as MeterInfoV20 @@ -45,17 +44,11 @@ def get_evse_id(self) -> str: raise NotImplementedError @abstractmethod - def get_supported_energy_transfer_modes( - self, as_enums: bool = False - ) -> Union[List[EnergyTransferMode], List[EnergyTransferModeEnum]]: + def get_supported_energy_transfer_modes(self) -> List[EnergyTransferModeEnum]: """ The MQTT interface needs to provide the information on the available energy transfer modes, which depends on the socket the EV is connected to - Args: - as_enums: Whether or not you want a list of EnergyTransferMode elements - or of the actual enum values (of type EnergyTransferModeEnum) - Relevant for: - ISO 15118-2 """ @@ -79,7 +72,7 @@ def is_authorised(self) -> bool: @abstractmethod def get_sa_schedule_list( self, max_schedule_entries: Optional[int], departure_time: int = 0 - ) -> Optional[List[SAScheduleTuple]]: + ) -> Optional[List[SAScheduleTupleEntry]]: """ Requests the charging schedule from a secondary actor (SA) like a charge point operator, if available. If no backend information is given @@ -96,7 +89,7 @@ def get_sa_schedule_list( implies the need to start charging immediately. Returns: - A list of SAScheduleTuple entries to influence the EV's charging profile + A list of SAScheduleTupleEntry values to influence the EV's charging profile if the backend/charger can provide the information already, or None if the calculation is still ongoing. diff --git a/iso15118/secc/controller/simulator.py b/iso15118/secc/controller/simulator.py index f148ad17..b01c6510 100644 --- a/iso15118/secc/controller/simulator.py +++ b/iso15118/secc/controller/simulator.py @@ -15,7 +15,6 @@ DCEVSEChargeParameter, DCEVSEStatus, DCEVSEStatusCode, - EnergyTransferMode, EnergyTransferModeEnum, EVSENotification, IsolationLevel, @@ -32,7 +31,6 @@ RelativeTimeInterval, SalesTariff, SalesTariffEntry, - SAScheduleTuple, SAScheduleTupleEntry, UnitSymbol, ) @@ -55,20 +53,10 @@ def get_evse_id(self) -> str: """Overrides EVSEControllerInterface.get_evse_id().""" return "UK123E1234" - def get_supported_energy_transfer_modes( - self, as_enums: bool = False - ) -> Union[List[EnergyTransferMode], List[EnergyTransferModeEnum]]: + def get_supported_energy_transfer_modes(self) -> List[EnergyTransferModeEnum]: """Overrides EVSEControllerInterface.get_supported_energy_transfer_modes().""" - ac_single_phase = EnergyTransferMode( - value=EnergyTransferModeEnum.AC_SINGLE_PHASE_CORE - ) - ac_three_phase = EnergyTransferMode( - value=EnergyTransferModeEnum.AC_THREE_PHASE_CORE - ) - - if as_enums: - return [ac_single_phase.value, ac_three_phase.value] - + ac_single_phase = EnergyTransferModeEnum.AC_SINGLE_PHASE_CORE + ac_three_phase = EnergyTransferModeEnum.AC_THREE_PHASE_CORE return [ac_single_phase, ac_three_phase] def is_authorised(self) -> bool: @@ -77,17 +65,17 @@ def is_authorised(self) -> bool: def get_sa_schedule_list( self, max_schedule_entries: Optional[int], departure_time: int = 0 - ) -> Optional[List[SAScheduleTuple]]: + ) -> Optional[List[SAScheduleTupleEntry]]: """Overrides EVSEControllerInterface.get_sa_schedule_list().""" - sa_schedule_list: List[SAScheduleTuple] = [] - p_max_schedule_entries: List[PMaxScheduleEntry] = [] + sa_schedule_list: List[SAScheduleTupleEntry] = [] # PMaxSchedule p_max = PVPMax(multiplier=0, value=11000, unit=UnitSymbol.WATT) entry_details = PMaxScheduleEntryDetails( p_max=p_max, time_interval=RelativeTimeInterval(start=0, duration=3600) ) - p_max_schedule_entries.append(PMaxScheduleEntry(entry_details=entry_details)) + p_max_schedule_entries = [entry_details] + p_max_schedule_entry = PMaxScheduleEntry(entry_details=p_max_schedule_entries) # SalesTariff sales_tariff_entries: List[SalesTariffEntry] = [] @@ -110,15 +98,15 @@ def get_sa_schedule_list( # Putting the list of SAScheduleTuple entries together sa_schedule_tuple_entry = SAScheduleTupleEntry( sa_schedule_tuple_id=1, - p_max_schedule=p_max_schedule_entries, + p_max_schedule=p_max_schedule_entry, sales_tariff=sales_tariff, ) - sa_schedule_tuple = SAScheduleTuple(tuple=sa_schedule_tuple_entry) + # TODO We could also implement an optional SalesTariff, but for the sake of # time we'll do that later (after the basics are implemented). # When implementing the SalesTariff, we also need to apply a digital # signature to it. - sa_schedule_list.append(sa_schedule_tuple) + sa_schedule_list.append(sa_schedule_tuple_entry) # TODO We need to take care of [V2G2-741], which says that the SECC needs to # resend a previously agreed SAScheduleTuple and the "period of time diff --git a/iso15118/secc/failed_responses.py b/iso15118/secc/failed_responses.py index f17f218b..0d6d72b0 100644 --- a/iso15118/secc/failed_responses.py +++ b/iso15118/secc/failed_responses.py @@ -62,7 +62,7 @@ from iso15118.shared.messages.iso15118_2.body import ( WeldingDetectionRes as WeldingDetectionResV2, ) -from iso15118.shared.messages.iso15118_2.datatypes import ACEVSEStatus, AuthOptions +from iso15118.shared.messages.iso15118_2.datatypes import ACEVSEStatus, AuthOptionList from iso15118.shared.messages.iso15118_2.datatypes import ( CertificateChain as CertificateChainV2, ) @@ -72,8 +72,8 @@ DCEVSEStatusCode, DHPublicKey, EncryptedPrivateKey, - EnergyTransferMode, EnergyTransferModeEnum, + EnergyTransferModeList, EVSENotification, EVSEProcessing, IsolationLevel, @@ -99,7 +99,6 @@ from iso15118.shared.messages.iso15118_20.common_messages import ( AuthorizationSetupReq, AuthorizationSetupRes, - Certificate, ) from iso15118.shared.messages.iso15118_20.common_messages import ( CertificateChain as CertificateChainV20, @@ -162,6 +161,9 @@ SessionStopRes as SessionStopResV20, ) from iso15118.shared.messages.iso15118_20.common_messages import SignedInstallationData +from iso15118.shared.messages.iso15118_20.common_messages import ( + SubCertificates as SubCertificatesV20, +) from iso15118.shared.messages.iso15118_20.common_types import ( MessageHeader as MessageHeaderV20, ) @@ -189,14 +191,14 @@ def init_failed_responses_iso_v2() -> dict: ), ServiceDiscoveryReqV2: ServiceDiscoveryResV2( response_code=ResponseCodeV2.FAILED, - auth_option_list=[AuthOptions(value=AuthEnum.EIM_V2)], + auth_option_list=AuthOptionList(auth_options=[AuthEnum.EIM_V2]), charge_service=ChargeService( service_id=ServiceID.CHARGING, service_category=ServiceCategory.CHARGING, free_service=False, - supported_energy_transfer_mode=[ - EnergyTransferMode(value=EnergyTransferModeEnum.DC_CORE) - ], + supported_energy_transfer_mode=EnergyTransferModeList( + energy_modes=[EnergyTransferModeEnum.DC_CORE] + ), ), ), ServiceDetailReqV2: ServiceDetailResV2( @@ -354,7 +356,7 @@ def init_failed_responses_iso_v20() -> dict: signed_installation_data=SignedInstallationData( contract_cert_chain=CertificateChainV20( certificate=bytes(0), - sub_certificates=[Certificate(certificate=bytes(0))], + sub_certificates=SubCertificatesV20(certificates=[bytes(0)]), ), ecdh_curve=ECDHCurve.x448, dh_public_key=bytes(0), diff --git a/iso15118/secc/states/iso15118_20_states.py b/iso15118/secc/states/iso15118_20_states.py index 3db375ef..b6864427 100644 --- a/iso15118/secc/states/iso15118_20_states.py +++ b/iso15118/secc/states/iso15118_20_states.py @@ -180,7 +180,7 @@ def process_message( auth_options.append(AuthEnum.PNC) pnc_as_res = PnCAuthSetupResParams( gen_challenge=get_random_bytes(16), - supported_providers=self.comm_session.evse_controller.get_supported_providers(), + supported_providers=self.comm_session.evse_controller.get_supported_providers(), # noqa: E501 ) # TODO [V2G20-2096], [V2G20-2570] diff --git a/iso15118/secc/states/iso15118_2_states.py b/iso15118/secc/states/iso15118_2_states.py index ee3c4b2b..a45a0a9e 100644 --- a/iso15118/secc/states/iso15118_2_states.py +++ b/iso15118/secc/states/iso15118_2_states.py @@ -62,8 +62,7 @@ from iso15118.shared.messages.iso15118_2.datatypes import ( ACEVSEChargeParameter, ACEVSEStatus, - AuthOptions, - Certificate, + AuthOptionList, CertificateChain, ChargeProgress, ChargeService, @@ -71,16 +70,19 @@ DCEVSEChargeParameter, DHPublicKey, EncryptedPrivateKey, + EnergyTransferModeList, EVSENotification, EVSEProcessing, Parameter, ParameterSet, - Service, + SAScheduleList, ServiceCategory, ServiceDetails, ServiceID, + ServiceList, ServiceName, ServiceParameterList, + SubCertificates, ) from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 from iso15118.shared.messages.iso15118_20.common_types import ( @@ -245,36 +247,38 @@ def get_services(self, category_filter: ServiceCategory) -> ServiceDiscoveryRes: Currently no filter based on service scope is applied since its string value is not standardized in any way """ - # The datatype in ISO 15118-2 is called "PaymentOption", but it's rather - # about the authorization method than about payment, thus 'auth_options' - auth_options: List[AuthOptions] = [] + auth_options: List[AuthEnum] = [] if 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: - auth_options.append(AuthOptions(value=AuthEnum.EIM_V2)) - self.comm_session.offered_auth_options.append(AuthEnum.EIM_V2) + auth_options.append(AuthEnum.EIM_V2) else: - auth_options.append(AuthOptions(value=AuthEnum.PNC_V2)) - self.comm_session.offered_auth_options.append(AuthEnum.PNC_V2) + auth_options.append(AuthEnum.PNC_V2) else: supported_auth_options = self.comm_session.config.supported_auth_options if AuthEnum.EIM in supported_auth_options: - auth_options.append(AuthOptions(value=AuthEnum.EIM_V2)) - self.comm_session.offered_auth_options.append(AuthEnum.EIM_V2) + auth_options.append(AuthEnum.EIM_V2) if AuthEnum.PNC in supported_auth_options and self.comm_session.is_tls: - auth_options.append(AuthOptions(value=AuthEnum.PNC_V2)) - self.comm_session.offered_auth_options.append(AuthEnum.PNC_V2) + auth_options.append(AuthEnum.PNC_V2) + + self.comm_session.offered_auth_options = auth_options + + energy_modes = ( + self.comm_session.evse_controller.get_supported_energy_transfer_modes() + ) 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=self.comm_session.evse_controller.get_supported_energy_transfer_modes(), + supported_energy_transfer_mode=EnergyTransferModeList( + energy_modes=energy_modes + ), ) - service_list: List[Service] = [] + service_list: List[ServiceDetails] = [] # Value-added services (VAS), like installation of contract certificates # and the Internet service, are only allowed with TLS-secured comm. if self.comm_session.is_tls: @@ -289,7 +293,7 @@ def get_services(self, category_filter: ServiceCategory) -> ServiceDiscoveryRes: free_service=self.comm_session.config.free_cert_install_service, ) - service_list.append(Service(service_details=cert_install_service)) + service_list.append(cert_install_service) # Add more value-added services (VAS) here if need be @@ -298,14 +302,13 @@ def get_services(self, category_filter: ServiceCategory) -> ServiceDiscoveryRes: # an EXI decoding error. The XSD definition does not allow an empty list # (otherwise it would also say: minOccurs="0"): # + offered_services = None if len(service_list) > 0: - offered_services = service_list - else: - offered_services = None + offered_services = ServiceList(services=service_list) service_discovery_res = ServiceDiscoveryRes( response_code=ResponseCode.OK, - auth_option_list=auth_options, + auth_option_list=AuthOptionList(auth_options=auth_options), charge_service=charge_service, service_list=offered_services, ) @@ -472,7 +475,7 @@ def process_message( charge_service_selected = True continue if service.service_id not in [ - offered_service.service_details.service_id + offered_service.service_id for offered_service in self.comm_session.offered_services ]: self.stop_state_machine( @@ -593,7 +596,7 @@ def process_message( except PrivateKeyReadError as exc: self.stop_state_machine( "Can't read private key to encrypt for " - f"CertificateInstallationRes: {exc.error}", + f"CertificateInstallationRes: {exc}", message, ResponseCode.FAILED, ) @@ -603,10 +606,12 @@ def process_message( contract_cert_chain = CertificateChain( id="id1", certificate=load_cert(CertPath.CONTRACT_LEAF_DER), - sub_certificates=[ - Certificate(certificate=load_cert(CertPath.MO_SUB_CA2_DER)), - Certificate(certificate=load_cert(CertPath.MO_SUB_CA1_DER)), - ], + sub_certificates=SubCertificates( + certificates=[ + load_cert(CertPath.MO_SUB_CA2_DER), + load_cert(CertPath.MO_SUB_CA1_DER), + ] + ), ) encrypted_priv_key = EncryptedPrivateKey( id="id2", value=encrypted_priv_key_bytes @@ -615,16 +620,19 @@ def process_message( emaid = EMAID( id="id4", value=get_cert_cn(load_cert(CertPath.CONTRACT_LEAF_DER)) ) + cps_certificate_chain = CertificateChain( + certificate=load_cert(CertPath.CPS_LEAF_DER), + sub_certificates=SubCertificates( + certificates=[ + load_cert(CertPath.CPS_SUB_CA2_DER), + load_cert(CertPath.CPS_SUB_CA1_DER), + ] + ), + ) cert_install_res = CertificateInstallationRes( response_code=ResponseCode.OK, - cps_cert_chain=CertificateChain( - certificate=load_cert(CertPath.CPS_LEAF_DER), - sub_certificates=[ - Certificate(certificate=load_cert(CertPath.CPS_SUB_CA2_DER)), - Certificate(certificate=load_cert(CertPath.CPS_SUB_CA1_DER)), - ], - ), + cps_cert_chain=cps_certificate_chain, contract_cert_chain=contract_cert_chain, encrypted_private_key=encrypted_priv_key, dh_public_key=dh_public_key, @@ -632,21 +640,31 @@ def process_message( ) try: - signature = create_signature( - [ - ( - contract_cert_chain.id, - to_exi(contract_cert_chain, Namespace.ISO_V2_MSG_DEF), - ), - ( - encrypted_priv_key.id, - to_exi(encrypted_priv_key, Namespace.ISO_V2_MSG_DEF), - ), - (dh_public_key.id, to_exi(dh_public_key, Namespace.ISO_V2_MSG_DEF)), - (emaid.id, to_exi(emaid, Namespace.ISO_V2_MSG_DEF)), - ], - load_priv_key(KeyPath.CPS_LEAF_PEM, KeyEncoding.PEM), + # Elements to sign, containing its id and the exi encoded stream + contract_cert_tuple = ( + contract_cert_chain.id, + to_exi(contract_cert_chain, Namespace.ISO_V2_MSG_DEF), ) + encrypted_priv_key_tuple = ( + encrypted_priv_key.id, + to_exi(encrypted_priv_key, Namespace.ISO_V2_MSG_DEF), + ) + dh_public_key_tuple = ( + dh_public_key.id, + to_exi(dh_public_key, Namespace.ISO_V2_MSG_DEF), + ) + emaid_tuple = (emaid.id, to_exi(emaid, Namespace.ISO_V2_MSG_DEF)) + + elements_to_sign = [ + contract_cert_tuple, + encrypted_priv_key_tuple, + dh_public_key_tuple, + emaid_tuple, + ] + # The private key to be used for the signature + signature_key = load_priv_key(KeyPath.CPS_LEAF_PEM, KeyEncoding.PEM) + + signature = create_signature(elements_to_sign, signature_key) self.create_next_message( PaymentDetails, @@ -658,7 +676,7 @@ def process_message( except PrivateKeyReadError as exc: self.stop_state_machine( "Can't read private key needed to create signature " - f"for CertificateInstallationRes: {exc.error}", + f"for CertificateInstallationRes: {exc}", message, ResponseCode.FAILED, ) @@ -708,10 +726,7 @@ def process_message( try: leaf_cert = payment_details_req.cert_chain.certificate - sub_ca_certs = [ - sub_ca_cert.certificate - for sub_ca_cert in payment_details_req.cert_chain.sub_certificates - ] + sub_ca_certs = payment_details_req.cert_chain.sub_certificates.certificates # TODO There should be an OCPP setting that determines whether # or not the charging station should verify (is in # possession of MO or V2G Root certificates) or if it @@ -796,7 +811,7 @@ class Authorization(StateSECC): In case of rejected and according to table 112 from ISO 15118-2, the errors allowed to be used are: FAILED, FAILED_Challenge_Invalid or FAILED_Certificate_Revoked. - Please check: https://dev.azure.com/switch-ev/Josev/_backlogs/backlog/Josev%20Team/Stories/?workitem=1049 + Please check: https://dev.azure.com/switch-ev/Josev/_backlogs/backlog/Josev%20Team/Stories/?workitem=1049 # noqa: E501 """ @@ -919,9 +934,7 @@ def process_message( if ( charge_params_req.requested_energy_mode - not in self.comm_session.evse_controller.get_supported_energy_transfer_modes( - True - ) + not in self.comm_session.evse_controller.get_supported_energy_transfer_modes() # noqa: E501 ): self.stop_state_machine( f"{charge_params_req.requested_energy_mode} not " @@ -961,7 +974,7 @@ def process_message( evse_processing=EVSEProcessing.FINISHED if sa_schedule_list else EVSEProcessing.ONGOING, - sa_schedule_list=sa_schedule_list, + sa_schedule_list=SAScheduleList(values=sa_schedule_list), ac_charge_parameter=ac_evse_charge_params, dc_charge_parameter=dc_evse_charge_params, ) @@ -977,24 +990,20 @@ def process_message( # operator (MO), but for testing purposes you can set it here # TODO We should probably have a test mode setting for schedule in sa_schedule_list: - if schedule.tuple.sales_tariff: + if schedule.sales_tariff: try: - signature = create_signature( - [ - ( - schedule.tuple.sales_tariff.id, - to_exi( - schedule.tuple.sales_tariff, - Namespace.ISO_V2_MSG_DEF, - ), - ) - ], - load_priv_key(KeyPath.MO_SUB_CA2_PEM, KeyEncoding.PEM), + element_to_sign = ( + schedule.sales_tariff.id, + to_exi(schedule.sales_tariff, Namespace.ISO_V2_MSG_DEF), + ) + signature_key = load_priv_key( + KeyPath.MO_SUB_CA2_PEM, KeyEncoding.PEM ) + signature = create_signature([element_to_sign], signature_key) except PrivateKeyReadError as exc: logger.warning( "Can't read private key to needed to create " - f"signature for SalesTariff: {exc.error}" + f"signature for SalesTariff: {exc}" ) # If a SalesTariff isn't signed, that's not the end of the # world, no reason to stop the charging process here @@ -1081,7 +1090,7 @@ def process_message( power_delivery_req: PowerDeliveryReq = msg.body.power_delivery_req if power_delivery_req.sa_schedule_tuple_id not in [ - schedule.tuple.sa_schedule_tuple_id + schedule.sa_schedule_tuple_id for schedule in self.comm_session.offered_schedules ]: self.stop_state_machine( diff --git a/iso15118/secc/states/secc_state.py b/iso15118/secc/states/secc_state.py index 94fbb06a..5d31e2ea 100644 --- a/iso15118/secc/states/secc_state.py +++ b/iso15118/secc/states/secc_state.py @@ -18,18 +18,12 @@ from iso15118.shared.messages.iso15118_2.body import ( SessionSetupReq as SessionSetupReqV2, ) -from iso15118.shared.messages.iso15118_2.body import ( - SessionSetupRes as SessionSetupResV2, -) from iso15118.shared.messages.iso15118_2.body import get_msg_type from iso15118.shared.messages.iso15118_2.datatypes import ResponseCode as ResponseCodeV2 from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 from iso15118.shared.messages.iso15118_20.common_messages import ( SessionSetupReq as SessionSetupReqV20, ) -from iso15118.shared.messages.iso15118_20.common_messages import ( - SessionSetupRes as SessionSetupResV20, -) from iso15118.shared.messages.iso15118_20.common_types import ( ResponseCode as ResponseCodeV20, ) @@ -158,7 +152,7 @@ def check_msg( or a PaymentDetailsReq, or an AuthorizationReq (according to the state machine outlined in ISO 15118-2). But when first entering the state, the EVCC must send a - PaymentServiceSelectionReq - that's what expect_first is about. + PaymentServiceSelectionReq - that's what expect_first is about. # noqa: E501 Set to True by default, as some states expect only one request message type. diff --git a/iso15118/shared/EXICodec.bkp.jar b/iso15118/shared/EXICodec.bkp.jar new file mode 100644 index 00000000..7dc9902e Binary files /dev/null and b/iso15118/shared/EXICodec.bkp.jar differ diff --git a/iso15118/shared/EXICodec.jar b/iso15118/shared/EXICodec.jar index 7dc9902e..d782dc94 100644 Binary files a/iso15118/shared/EXICodec.jar and b/iso15118/shared/EXICodec.jar differ diff --git a/iso15118/shared/comm_session.py b/iso15118/shared/comm_session.py index 6e1639c9..4e8a829a 100644 --- a/iso15118/shared/comm_session.py +++ b/iso15118/shared/comm_session.py @@ -34,7 +34,6 @@ from iso15118.shared.messages.iso15118_20.common_types import ( V2GMessage as V2GMessageV20, ) -from iso15118.shared.messages.sdp import Security from iso15118.shared.messages.v2gtp import V2GTPMessage from iso15118.shared.notifications import StopNotification from iso15118.shared.states import Pause, State, Terminate @@ -179,8 +178,7 @@ def process_message(self, message: bytes): try: decoded_message = from_exi(v2gtp_msg.payload, self.get_exi_ns()) except EXIDecodingError as exc: - logger.error(f"{exc.error}") - self.current_state.next_state = Terminate + logger.exception(f"{exc}") raise exc # Shouldn't happen, but just to be sure (otherwise mypy would complain) @@ -201,7 +199,7 @@ def process_message(self, message: bytes): ) raise exc except FaultyStateImplementationError as exc: - logger.exception(f"{exc.__class__.__name__}: {exc.error}") + logger.exception(f"{exc.__class__.__name__}: {exc}") raise exc except ValidationError as exc: logger.exception(f"{exc.__class__.__name__}: {exc.raw_errors}") @@ -313,7 +311,7 @@ async def start(self, timeout: float): def save_session_info(self): raise NotImplementedError - async def stop(self): + async def stop(self, reason: str): """ Closes the TCP connection after 5 seconds and terminates or pauses the data link for this V2GCommunicationSession object after 2 seconds to @@ -333,18 +331,18 @@ async def stop(self): self.save_session_info() terminate_or_pause = "Pause" else: - terminate_or_pause = "Terminat" + terminate_or_pause = "Terminate" logger.debug( - f"{terminate_or_pause}ing the data link in 2 seconds and " - "closing the TCP connection in 5 seconds. " - f"Reason: {self.stop_reason.reason}" + f"The data link will {terminate_or_pause} in 2 seconds and " + "the TCP connection will close in 5 seconds. " + f"Reason: {reason}" ) await asyncio.sleep(2) # TODO Signal data link layer to either terminate or pause the data # link connection - logger.debug(f"{terminate_or_pause}ed the data link") + logger.debug(f"{terminate_or_pause}d the data link") await asyncio.sleep(3) self.writer.close() await self.writer.wait_closed() @@ -389,11 +387,12 @@ async def rcv_loop(self, timeout: float): message = await asyncio.wait_for(self.reader.read(7000), timeout) if message == b"" and self.reader.at_eof(): - await self.stop() + stop_reason: str = "TCP peer closed connection" + await self.stop(reason=stop_reason) self.session_handler_queue.put_nowait( StopNotification( False, - "TCP peer closed connection", + stop_reason, self.writer.get_extra_info("peername"), ) ) @@ -417,7 +416,7 @@ async def rcv_loop(self, timeout: float): False, error_msg, self.writer.get_extra_info("peername") ) - await self.stop() + await self.stop(reason=error_msg) self.session_handler_queue.put_nowait(self.stop_reason) return @@ -432,7 +431,7 @@ async def rcv_loop(self, timeout: float): await self.send(self.current_state.next_v2gtp_msg) if self.current_state.next_state in (Terminate, Pause): - await self.stop() + await self.stop(reason="") self.comm_session.session_handler_queue.put_nowait( self.comm_session.stop_reason ) @@ -450,28 +449,36 @@ async def rcv_loop(self, timeout: float): if isinstance(exc, MessageProcessingError): message_name = exc.message_name if isinstance(exc, FaultyStateImplementationError): - additional_info = ": " + exc.error + additional_info = f": {exc}" if isinstance(exc, EXIDecodingError): - additional_info = ": " + exc.__str__() + additional_info = f": {exc}" + + stop_reason: str = ( + f"{exc.__class__.__name__} occurred while processing message " + f"{message_name} in state {str(self.current_state)}" + f":{additional_info}" + ) self.stop_reason = StopNotification( False, - f"{exc.__class__.__name__} occurred while processing message " - f"{message_name} in state {str(self.current_state)}{additional_info}", + stop_reason, self.writer.get_extra_info("peername"), ) - await self.stop() + await self.stop(stop_reason) self.session_handler_queue.put_nowait(self.stop_reason) return except (AttributeError, ValueError) as exc: + stop_reason: str = ( + f"{exc.__class__.__name__} occurred while processing message in " + f"state {str(self.current_state)}: {exc}" + ) self.stop_reason = StopNotification( False, - f"{exc.__class__.__name__} occurred while processing message in " - f"state {str(self.current_state)}: {exc}", + stop_reason, self.writer.get_extra_info("peername"), ) - await self.stop() + await self.stop(stop_reason) self.session_handler_queue.put_nowait(self.stop_reason) return diff --git a/iso15118/shared/exceptions.py b/iso15118/shared/exceptions.py index 05f867fe..69123c8d 100644 --- a/iso15118/shared/exceptions.py +++ b/iso15118/shared/exceptions.py @@ -70,10 +70,6 @@ class FaultyStateImplementationError(Exception): information as to what specifically is wrong. """ - def __init__(self, error: str): - Exception.__init__(self) - self.error = error - class InvalidPayloadTypeError(Exception): """Is thrown when trying to instantiate a V2GTPMessage object with a @@ -92,18 +88,10 @@ class InvalidProtocolError(Exception): class EXIEncodingError(Exception): """Is thrown when trying to serialise anobject into an EXI byte stream""" - def __init__(self, error: str): - Exception.__init__(self) - self.error = error - class EXIDecodingError(Exception): """Is thrown when trying to EXI decode an incoming byte stream""" - def __init__(self, error: str): - Exception.__init__(self) - self.error = error - class InvalidSettingsValueError(Exception): """ @@ -214,28 +202,14 @@ def __init__(self, allowed_num_sub_cas: int, num_sub_cas: int): class EncryptionError(Exception): """Is thrown when an error occurs while trying to encrypt a private key""" - def __init__(self): - Exception.__init__(self) - class DecryptionError(Exception): """Is thrown when an error occurs while trying to decrypt a private key""" - def __init__(self): - Exception.__init__(self) - class KeyTypeError(Exception): """Is thrown when loading a private key whose type is not recognised""" - def __init__(self, error: str): - Exception.__init__(self) - self.error = error - class PrivateKeyReadError(Exception): """Is thrown when an error occurs while trying to load a private key""" - - def __init__(self, error: str): - Exception.__init__(self) - self.error = error diff --git a/iso15118/shared/exi_codec.py b/iso15118/shared/exi_codec.py index ba01cff9..b59a98d6 100644 --- a/iso15118/shared/exi_codec.py +++ b/iso15118/shared/exi_codec.py @@ -105,6 +105,18 @@ def object_hook(self, dct) -> dict: if len(dct[field]) <= 15: continue + if field == "Certificate" and isinstance(dct[field], list): + # The types CertificateChain and SubCertificates both have fields + # with the name `Certificate`. However, in `CertificateChain` + # the field is of the type bytes, whilst in `SubCertificates` is + # of the type list[bytes]. + # This difference needs to be taken into account; so here we look + # for the list type, decode its elements and substitute the entry + # in the dict with the new list. + certificate_list = [b64decode(value) for value in dct[field]] + dct[field] = certificate_list + continue + dct[field] = b64decode(dct[field]) return dct @@ -130,6 +142,8 @@ def to_exi(msg_element: BaseModel, protocol_ns: str) -> bytes: str(msg_element) == "CertificateChain" and protocol_ns == Namespace.ISO_V2_MSG_DEF ): + # TODO: If we add `ContractSignatureCertChain` as the return of __str__ + # for the CertificateChain class, do we still need this if clause? # In case of CertificateInstallationRes and CertificateUpdateRes, # str(message) would not be 'ContractSignatureCertChain' but # 'CertificateChain' (the type of ContractSignatureCertChain) @@ -137,11 +151,16 @@ def to_exi(msg_element: BaseModel, protocol_ns: str) -> bytes: elif str(msg_element) == "CertificateChain" and protocol_ns.startswith( Namespace.ISO_V20_BASE ): + # TODO: If we add `CPSCertificateChain` as the return of __str__ + # for a unique class for V20 or even call it CPSCertificateChain + # do we still need this if clause? # In case of CertificateInstallationRes, # str(message) would not be 'CPSCertificateChain' but # 'CertificateChain' (the type of CPSCertificateChain) message_dict = {"CPSCertificateChain": msg_to_dct} elif str(msg_element) == "SignedCertificateChain": + # TODO: If we add `OEMProvisioningCertificateChain` as the return of __str__ + # for the SignedCertificateChain class, do we still need this if clause? # In case of CertificateInstallationReq, # str(message) would not be 'OEMProvisioningCertificateChain' but # 'SignedCertificateChain' (the type of OEMProvisioningCertificateChain) @@ -211,11 +230,18 @@ def from_exi( ) try: - decoded_dict = json.loads( - exi_codec.decode(exi_message, namespace), cls=CustomJSONDecoder - ) + exi_decoded = exi_codec.decode(exi_message, namespace) except Exception as exc: - raise EXIDecodingError(f"EXIDecodingError: {exc}") from exc + raise EXIDecodingError( + f"EXIDecodingError ({exc.__class__.__name__}): " f"{exc}" + ) from exc + try: + decoded_dict = json.loads(exi_decoded, cls=CustomJSONDecoder) + except json.JSONDecodeError as exc: + raise EXIDecodingError( + f"JSON decoding error ({exc.__class__.__name__}) while " + f"processing decoded EXI: {exc}" + ) from exc if MESSAGE_LOG_JSON: logger.debug( @@ -236,7 +262,7 @@ def from_exi( if namespace == Namespace.ISO_V2_MSG_DEF: return V2GMessageV2.parse_obj(decoded_dict["V2G_Message"]) - if namespace.startswith("urn:iso:std:iso:15118:-20"): + if namespace.startswith(Namespace.ISO_V20_BASE): # The message name is the first key of the dict msg_name = next(iter(decoded_dict)) # When parsing the dict, we need to remove the first key, which is @@ -267,10 +293,13 @@ def from_exi( # TODO Add support for DIN SPEC 70121 - raise EXIDecodingError( - "EXI decoding error: can't identify protocol to " "use for decoding" - ) + 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}" + ) from exc + except EXIDecodingError as exc: raise EXIDecodingError( f"EXI decoding error: {exc}. \n\nDecoded dict: " f"{decoded_dict}" ) from exc diff --git a/iso15118/shared/exificient_wrapper.py b/iso15118/shared/exificient_wrapper.py index 7438958c..558c08a9 100644 --- a/iso15118/shared/exificient_wrapper.py +++ b/iso15118/shared/exificient_wrapper.py @@ -26,16 +26,16 @@ def __init__(self): ) self.exi_codec = self.gateway.jvm.com.siemens.ct.exi.main.cmd.EXICodec() - + schemas = self.gateway.jvm.com.siemens.ct.exi.main.cmd.BuiltInSchema self.protocol_schema_mapping = { - "": self.gateway.jvm.com.siemens.ct.exi.main.cmd.BuiltInSchema.AppProtocol, - "urn:iso:15118:2:2013:MsgDef": self.gateway.jvm.com.siemens.ct.exi.main.cmd.BuiltInSchema.ISO15118_2, - "urn:iso:std:iso:15118:-20:CommonMessages": self.gateway.jvm.com.siemens.ct.exi.main.cmd.BuiltInSchema.ISO15118_20_V2G_CI_CommonMessages, - "urn:iso:std:iso:15118:-20:AC": self.gateway.jvm.com.siemens.ct.exi.main.cmd.BuiltInSchema.ISO15118_20_V2G_CI_AC, - "urn:iso:std:iso:15118:-20:DC": self.gateway.jvm.com.siemens.ct.exi.main.cmd.BuiltInSchema.ISO15118_20_V2G_CI_DC, - "urn:iso:std:iso:15118:-20:WPT": self.gateway.jvm.com.siemens.ct.exi.main.cmd.BuiltInSchema.ISO15118_20_V2G_CI_WPT, - "urn:iso:std:iso:15118:-20:ACDP": self.gateway.jvm.com.siemens.ct.exi.main.cmd.BuiltInSchema.ISO15118_20_V2G_CI_ACDP, - "http://www.w3.org/2000/09/xmldsig#": self.gateway.jvm.com.siemens.ct.exi.main.cmd.BuiltInSchema.XSDCore, + "": schemas.AppProtocol, + "urn:iso:15118:2:2013:MsgDef": schemas.ISO15118_2, + "urn:iso:std:iso:15118:-20:CommonMessages": schemas.ISO15118_20_V2G_CI_CommonMessages, # noqa: E501 + "urn:iso:std:iso:15118:-20:AC": schemas.ISO15118_20_V2G_CI_AC, + "urn:iso:std:iso:15118:-20:DC": schemas.ISO15118_20_V2G_CI_DC, + "urn:iso:std:iso:15118:-20:WPT": schemas.ISO15118_20_V2G_CI_WPT, + "urn:iso:std:iso:15118:-20:ACDP": schemas.ISO15118_20_V2G_CI_ACDP, + "http://www.w3.org/2000/09/xmldsig#": schemas.XSDCore, } logger.debug(f"EXICodec version: {self.exi_codec.get_version()}") diff --git a/iso15118/shared/messages/enums.py b/iso15118/shared/messages/enums.py index f74ae9ef..506bbda5 100644 --- a/iso15118/shared/messages/enums.py +++ b/iso15118/shared/messages/enums.py @@ -122,12 +122,12 @@ class Namespace(str, Enum): ISO_V2_MSG_BODY = "urn:iso:15118:2:2013:MsgBody" ISO_V2_MSG_DT = "urn:iso:15118:2:2013:MsgDataTypes" ISO_V20_BASE = "urn:iso:std:iso:15118:-20" - ISO_V20_COMMON_MSG = "urn:iso:std:iso:15118:-20:CommonMessages" - ISO_V20_COMMON_TYPES = "urn:iso:std:iso:15118:-20:CommonTypes" - ISO_V20_AC = "urn:iso:std:iso:15118:-20:AC" - ISO_V20_DC = "urn:iso:std:iso:15118:-20:DC" - ISO_V20_WPT = "urn:iso:std:iso:15118:-20:WPT" - ISO_V20_ACDP = "urn:iso:std:iso:15118:-20:ACDP" + ISO_V20_COMMON_MSG = ISO_V20_BASE + ":CommonMessages" + ISO_V20_COMMON_TYPES = ISO_V20_BASE + ":CommonTypes" + ISO_V20_AC = ISO_V20_BASE + ":AC" + ISO_V20_DC = ISO_V20_BASE + ":DC" + ISO_V20_WPT = ISO_V20_BASE + ":WPT" + ISO_V20_ACDP = ISO_V20_BASE + ":ACDP" XML_DSIG = "http://www.w3.org/2000/09/xmldsig#" SAP = "urn:iso:15118:2:2010:AppProtocol" diff --git a/iso15118/shared/messages/iso15118_2/body.py b/iso15118/shared/messages/iso15118_2/body.py index 2c802897..43d77371 100644 --- a/iso15118/shared/messages/iso15118_2/body.py +++ b/iso15118/shared/messages/iso15118_2/body.py @@ -11,7 +11,7 @@ """ import logging from abc import ABC -from typing import List, Optional, Tuple, Type +from typing import Optional, Tuple, Type from pydantic import Field, root_validator, validator @@ -22,10 +22,11 @@ ACEVChargeParameter, ACEVSEChargeParameter, ACEVSEStatus, - AuthOptions, + AuthOptionList, CertificateChain, ChargeProgress, ChargeService, + ChargingProfile, ChargingSession, DCEVChargeParameter, DCEVPowerDeliveryParameter, @@ -37,7 +38,6 @@ EnergyTransferModeEnum, EVSEProcessing, MeterInfo, - ProfileEntry, PVEVMaxCurrentLimit, PVEVMaxPowerLimit, PVEVMaxVoltageLimit, @@ -52,12 +52,13 @@ PVRemainingTimeToBulkSOC, PVRemainingTimeToFullSOC, ResponseCode, - RootCertificateID, - SAScheduleTuple, + RootCertificateIDList, + SAScheduleList, SelectedServiceList, - Service, ServiceCategory, + ServiceList, ServiceParameterList, + eMAID, ) from iso15118.shared.validators import one_field_must_be_set @@ -144,8 +145,8 @@ class CertificateInstallationReq(BodyBase): oem_provisioning_cert: bytes = Field( ..., max_length=800, alias="OEMProvisioningCert" ) - root_cert_ids: List[RootCertificateID] = Field( - ..., max_items=20, alias="ListOfRootCertificateIDs" + list_of_root_cert_ids: RootCertificateIDList = Field( + ..., alias="ListOfRootCertificateIDs" ) @@ -173,7 +174,7 @@ class CertificateUpdateReq(BodyBase): ..., alias="ContractSignatureCertChain" ) emaid: EMAID = Field(..., alias="eMAID") - list_of_root_cert_ids: List[RootCertificateID] = Field( + list_of_root_cert_ids: RootCertificateIDList = Field( ..., alias="ListOfRootCertificateIDs" ) @@ -274,9 +275,7 @@ class ChargeParameterDiscoveryRes(Response): """See section 8.4.3.8.3 in ISO 15118-2""" evse_processing: EVSEProcessing = Field(..., alias="EVSEProcessing") - sa_schedule_list: List[SAScheduleTuple] = Field( - None, max_items=3, alias="SAScheduleList" - ) + sa_schedule_list: SAScheduleList = Field(None, alias="SAScheduleList") ac_charge_parameter: ACEVSEChargeParameter = Field( None, alias="AC_EVSEChargeParameter" ) @@ -416,7 +415,8 @@ def check_sessionid_is_hexbinary(cls, value): # pylint: disable=no-self-argument # pylint: disable=no-self-use try: - test = int(value, 16) + # convert value to int, assuming base 16 + int(value, 16) return value except ValueError as exc: raise ValueError( @@ -456,7 +456,7 @@ class MeteringReceiptRes(Response): class PaymentDetailsReq(BodyBase): """See section 8.4.3.6.2 in ISO 15118-2""" - emaid: EMAID = Field(..., alias="eMAID") + emaid: eMAID = Field(..., alias="eMAID") cert_chain: CertificateChain = Field(..., alias="ContractSignatureCertChain") @@ -492,9 +492,7 @@ class PowerDeliveryReq(BodyBase): charge_progress: ChargeProgress = Field(..., alias="ChargeProgress") # XSD type unsignedByte with value range [1..255] sa_schedule_tuple_id: int = Field(..., ge=1, le=255, alias="SAScheduleTupleID") - charging_profile: List[ProfileEntry] = Field( - None, max_items=24, alias="ChargingProfile" - ) + charging_profile: ChargingProfile = Field(None, alias="ChargingProfile") dc_ev_power_delivery_parameter: DCEVPowerDeliveryParameter = Field( None, alias="DC_EVPowerDeliveryParameter" ) @@ -570,11 +568,9 @@ class ServiceDiscoveryReq(BodyBase): class ServiceDiscoveryRes(Response): """See section 8.4.3.3.3 in ISO 15118-2""" - auth_option_list: List[AuthOptions] = Field( - ..., min_items=1, max_items=2, alias="PaymentOptionList" - ) + auth_option_list: AuthOptionList = Field(..., alias="PaymentOptionList") charge_service: ChargeService = Field(..., alias="ChargeService") - service_list: List[Service] = Field(None, max_items=8, alias="ServiceList") + service_list: ServiceList = Field(None, alias="ServiceList") class SessionSetupReq(BodyBase): @@ -595,7 +591,8 @@ def check_sessionid_is_hexbinary(cls, value): # pylint: disable=no-self-argument # pylint: disable=no-self-use try: - test = int(value, 16) + # convert value to int, assuming base 16 + int(value, 16) return value except ValueError as exc: raise ValueError( diff --git a/iso15118/shared/messages/iso15118_2/datatypes.py b/iso15118/shared/messages/iso15118_2/datatypes.py index 82ba8161..f0e3ead8 100644 --- a/iso15118/shared/messages/iso15118_2/datatypes.py +++ b/iso15118/shared/messages/iso15118_2/datatypes.py @@ -14,7 +14,7 @@ from enum import Enum, IntEnum from typing import List, Literal -from pydantic import Field, root_validator, validator +from pydantic import Field, conbytes, constr, root_validator, validator from iso15118.shared.messages import BaseModel from iso15118.shared.messages.enums import ( @@ -28,6 +28,13 @@ from iso15118.shared.messages.xmldsig import X509IssuerSerial from iso15118.shared.validators import one_field_must_be_set +# https://pydantic-docs.helpmanual.io/usage/types/#constrained-types +# constrained types +# Check Annex C.6 or the certificateType in V2G_CI_MsgDataTypes.xsd +Certificate = conbytes(max_length=800) +# Check Annex C.6 or the eMAIDType in V2G_CI_MsgDataTypes.xsd +eMAID = constr(min_length=14, max_length=15) + class UnitSymbol(str, Enum): """ @@ -404,20 +411,24 @@ class ACEVSEChargeParameter(BaseModel): evse_max_current: PVEVSEMaxCurrent = Field(..., alias="EVSEMaxCurrent") -class Certificate(BaseModel): - """See sections 8.5.2.5 and 8.5.2.26 in ISO 15118-2""" +class SubCertificates(BaseModel): + """See sections 8.5.2.5 and 8.5.2.26 in ISO 15118-2 + + According to the schemas, SubCertificates can contain up to 4 certificates. + However, according to requirement [V2G2-656]: + `The number of Certificates in the SubCertificates shall not exceed 2` + So, we set it here to 2, the max number of certificates allowed. + """ - certificate: bytes = Field(..., max_length=800, alias="Certificate") + certificates: List[Certificate] = Field(..., max_items=2, alias="Certificate") class CertificateChain(BaseModel): """See section 8.5.2.5 in ISO 15118-2""" id: str = Field(None, alias="Id") - # Note that the type here must be bytes and not Certificate, otherwise we - # end up with a json structure that does not match the XSD schema - certificate: bytes = Field(..., max_length=800, alias="Certificate") - sub_certificates: List[Certificate] = Field(None, alias="SubCertificates") + certificate: Certificate = Field(..., alias="Certificate") + sub_certificates: SubCertificates = Field(None, alias="SubCertificates") def __str__(self): return type(self).__name__ @@ -442,10 +453,12 @@ class EnergyTransferModeEnum(str, Enum): DC_UNIQUE = "DC_unique" -class EnergyTransferMode(BaseModel): +class EnergyTransferModeList(BaseModel): """See section 8.5.2.4 in ISO 15118-2""" - value: EnergyTransferModeEnum = Field(..., alias="EnergyTransferMode") + energy_modes: List[EnergyTransferModeEnum] = Field( + ..., max_items=6, alias="EnergyTransferMode" + ) class ServiceID(IntEnum): @@ -493,8 +506,8 @@ class ServiceDetails(BaseModel): class ChargeService(ServiceDetails): """See section 8.5.2.3 in ISO 15118-2""" - supported_energy_transfer_mode: List[EnergyTransferMode] = Field( - ..., max_items=6, alias="SupportedEnergyTransferMode" + supported_energy_transfer_mode: EnergyTransferModeList = Field( + ..., alias="SupportedEnergyTransferMode" ) @@ -509,10 +522,12 @@ class ProfileEntryDetails(BaseModel): ) -class ProfileEntry(BaseModel): +class ChargingProfile(BaseModel): """See section 8.5.2.10 in ISO 15118-2""" - entry_details: ProfileEntryDetails = Field(..., alias="ProfileEntry") + profile_entries: List[ProfileEntryDetails] = Field( + ..., max_items=24, alias="ProfileEntry" + ) class ChargingSession(str, Enum): @@ -682,7 +697,16 @@ class DCEVSEChargeParameter(BaseModel): class DHPublicKey(BaseModel): - """See section 8.5.2.29 in ISO 15118-2""" + """See section 8.5.2.29 in ISO 15118-2 + + 'Id' is actually an XML attribute, but JSON (our serialisation method) + doesn't have attributes. The EXI codec has to en-/decode accordingly. + id: str = Field(..., alias="Id") + The XSD doesn't explicitly state a Value element for + DiffieHellmanPublickeyType but its base XSD type named + dHpublickeyType has an XSD element . That's why + we add this 'value' field + """ id: str = Field(..., alias="Id") value: bytes = Field(..., max_length=65, alias="value") @@ -709,10 +733,12 @@ class FaultCode(str, Enum): UNKNOWN_ERROR = "UnknownError" -class RootCertificateID(BaseModel): +class RootCertificateIDList(BaseModel): """See section 8.5.2.27 in ISO 15118-2""" - x509_issuer_serial: X509IssuerSerial = Field(..., alias="RootCertificateID") + x509_issuer_serials: List[X509IssuerSerial] = Field( + ..., max_items=20, alias="RootCertificateID" + ) class MeterInfo(BaseModel): @@ -798,7 +824,7 @@ class ParameterSet(BaseModel): parameters: List[Parameter] = Field(..., max_items=16, alias="Parameter") -class AuthOptions(BaseModel): +class AuthOptionList(BaseModel): """ See section 8.5.2.9 in ISO 15118-2 @@ -806,7 +832,9 @@ class AuthOptions(BaseModel): about the authorization method than about payment, thus the name AuthOption """ - value: AuthEnum = Field(..., alias="PaymentOption") + auth_options: List[AuthEnum] = Field( + ..., min_items=1, max_items=2, alias="PaymentOption" + ) class RelativeTimeInterval(BaseModel): @@ -826,7 +854,9 @@ class PMaxScheduleEntryDetails(BaseModel): class PMaxScheduleEntry(BaseModel): """See section 8.5.2.14 in ISO 15118-2""" - entry_details: PMaxScheduleEntryDetails = Field(..., alias="PMaxScheduleEntry") + entry_details: List[PMaxScheduleEntryDetails] = Field( + ..., max_items=1024, alias="PMaxScheduleEntry" + ) class ResponseCode(str, Enum): @@ -881,10 +911,10 @@ class SelectedServiceList(BaseModel): ) -class Service(BaseModel): +class ServiceList(BaseModel): """See section 8.5.2.2 in ISO 15118-2""" - service_details: ServiceDetails = Field(..., alias="Service") + services: List[ServiceDetails] = Field(..., max_items=8, alias="Service") class ServiceParameterList(BaseModel): @@ -995,7 +1025,7 @@ def sales_tariff_id_value_range(cls, value): def __str__(self): # The XSD conform element name - return "SalesTariff" + return type(self).__name__ class SAScheduleTupleEntry(BaseModel): @@ -1003,23 +1033,29 @@ class SAScheduleTupleEntry(BaseModel): # XSD type unsignedByte with value range [1..255] sa_schedule_tuple_id: int = Field(..., ge=1, le=255, alias="SAScheduleTupleID") - p_max_schedule: List[PMaxScheduleEntry] = Field( - ..., max_items=1024, alias="PMaxSchedule" - ) + p_max_schedule: PMaxScheduleEntry = Field(..., alias="PMaxSchedule") sales_tariff: SalesTariff = Field(None, alias="SalesTariff") -class SAScheduleTuple(BaseModel): - """See section 8.5.2.13 in ISO 15118-2""" - - tuple: SAScheduleTupleEntry = Field(..., alias="SAScheduleTuple") +class SAScheduleList(BaseModel): + values: List[SAScheduleTupleEntry] = Field( + ..., max_items=3, alias="SAScheduleTuple" + ) class EMAID(BaseModel): - # 'Id' is actually an XML attribute, but JSON (our serialisation method) - # doesn't have attributes. The EXI codec has to en-/decode accordingly. + """ + This is the complex datatype defined in the XML schemas as EMAIDType, containing + an id attribute; not to be confused with the simple type, that EMAID is + derived from, called eMAIDType, which is of string type, with a min length of + 14 and a max length of 15 characters. + + 'Id' is actually an XML attribute, but JSON (our serialisation method) + doesn't have attributes. The EXI codec has to en-/decode accordingly. + """ + id: str = Field(None, alias="Id") - value: str = Field(..., min_length=14, max_length=15, alias="value") + value: eMAID = Field(..., alias="value") def __str__(self): # The XSD conform element name diff --git a/iso15118/shared/messages/iso15118_2/header.py b/iso15118/shared/messages/iso15118_2/header.py index 01afda7c..86029298 100644 --- a/iso15118/shared/messages/iso15118_2/header.py +++ b/iso15118/shared/messages/iso15118_2/header.py @@ -39,7 +39,8 @@ def check_sessionid_is_hexbinary(cls, value): # pylint: disable=no-self-argument # pylint: disable=no-self-use try: - test = int(value, 16) + # convert value to int, assuming base 16 + int(value, 16) return value except ValueError as exc: raise ValueError( diff --git a/iso15118/shared/messages/iso15118_20/common_messages.py b/iso15118/shared/messages/iso15118_20/common_messages.py index 168038ba..7a61b7b6 100644 --- a/iso15118/shared/messages/iso15118_20/common_messages.py +++ b/iso15118/shared/messages/iso15118_20/common_messages.py @@ -20,7 +20,9 @@ from iso15118.shared.messages.enums import AuthEnum from iso15118.shared.messages.iso15118_20.common_types import ( UINT_32_MAX, + Certificate, EVSEStatus, + Identifier, MeterInfo, Processing, RationalNumber, @@ -42,16 +44,16 @@ class ECDHCurve(str, Enum): x448 = "X448" -class EMAID(BaseModel): +class EMAIDList(BaseModel): """See Annex C.1 in ISO 15118-20""" - emaid: str = Field(..., max_length=255, alias="EMAID") + emaids: List[Identifier] = Field(..., max_items=8, alias="EMAID") -class Certificate(BaseModel): - """A DER encoded X.509 certificate""" +class SubCertificates(BaseModel): + """See Annex C.1 or V2G_CI_CommonTypes.xsd in ISO 15118-20""" - certificate: bytes = Field(..., max_length=800, alias="Certificate") + certificates: List[Certificate] = Field(..., max_items=3, alias="Certificate") class CertificateChain(BaseModel): @@ -59,8 +61,8 @@ class CertificateChain(BaseModel): # Note that the type here must be bytes and not Certificate, otherwise we # end up with a json structure that does not match the XSD schema - certificate: bytes = Field(..., max_length=800, alias="Certificate") - sub_certificates: List[Certificate] = Field(None, alias="SubCertificates") + certificate: Certificate = Field(..., alias="Certificate") + sub_certificates: SubCertificates = Field(None, alias="SubCertificates") class SignedCertificateChain(BaseModel): @@ -71,8 +73,8 @@ class SignedCertificateChain(BaseModel): id: str = Field(..., max_length=255, alias="Id") # Note that the type here must be bytes and not Certificate, otherwise we # end up with a json structure that does not match the XSD schema - certificate: bytes = Field(..., max_length=800, alias="Certificate") - sub_certificates: List[Certificate] = Field(None, alias="SubCertificates") + certificate: Certificate = Field(..., alias="Certificate") + sub_certificates: SubCertificates = Field(None, alias="SubCertificates") def __str__(self): return type(self).__name__ @@ -83,8 +85,8 @@ class ContractCertificateChain(BaseModel): # Note that the type here must be bytes and not Certificate, otherwise we # end up with a json structure that does not match the XSD schema - certificate: bytes = Field(..., max_length=800, alias="Certificate") - sub_certificates: List[Certificate] = Field(..., alias="SubCertificates") + certificate: Certificate = Field(..., alias="Certificate") + sub_certificates: SubCertificates = Field(..., alias="SubCertificates") class SessionSetupReq(V2GRequest): @@ -957,7 +959,8 @@ def check_sessionid_is_hexbinary(cls, value): # pylint: disable=no-self-argument # pylint: disable=no-self-use try: - test = int(value, 16) + # convert value to int, assuming base 16 + int(value, 16) return value except ValueError as exc: raise ValueError( @@ -1004,16 +1007,14 @@ class CertificateInstallationReq(V2GRequest): oem_prov_cert_chain: SignedCertificateChain = Field( ..., alias="OEMProvisioningCertificateChain" ) - list_of_root_cert_ids: List[RootCertificateID] = Field( - ..., max_items=20, alias="ListOfRootCertificateIDs" + list_of_root_cert_ids: RootCertificateID = Field( + ..., alias="ListOfRootCertificateIDs" ) # XSD type unsignedShort (16 bit integer) with value range [0..65535] max_contract_cert_chains: int = Field( ..., ge=0, le=65535, alias="MaximumContractCertificateChains" ) - prioritized_emaids: List[EMAID] = Field( - None, max_items=8, alias="PrioritizedEMAIDs" - ) + prioritized_emaids: EMAIDList = Field(None, alias="PrioritizedEMAIDs") class SignedInstallationData(BaseModel): diff --git a/iso15118/shared/messages/iso15118_20/common_types.py b/iso15118/shared/messages/iso15118_20/common_types.py index 9f84520c..509ac7f3 100644 --- a/iso15118/shared/messages/iso15118_20/common_types.py +++ b/iso15118/shared/messages/iso15118_20/common_types.py @@ -14,12 +14,18 @@ from enum import Enum from typing import List -from pydantic import Field, validator +from pydantic import Field, conbytes, constr, validator from iso15118.shared.messages import BaseModel from iso15118.shared.messages.enums import UINT_32_MAX from iso15118.shared.messages.xmldsig import Signature, X509IssuerSerial +# https://pydantic-docs.helpmanual.io/usage/types/#constrained-types +# constrained types +# Check Annex C.1 or V2G_CI_CommonTypes.xsd +Certificate = conbytes(max_length=1600) +Identifier = constr(max_length=255) + class MessageHeader(BaseModel): """See section 8.3.3 in ISO 15118-20""" @@ -41,7 +47,8 @@ def check_sessionid_is_hexbinary(cls, value): # pylint: disable=no-self-argument # pylint: disable=no-self-use try: - test = int(value, 16) + # convert value to int, assuming base 16 + int(value, 16) return value except ValueError as exc: raise ValueError( @@ -319,4 +326,6 @@ class Processing(str, Enum): class RootCertificateID(BaseModel): """See section 8.3.5.3.27 in ISO 15118-20""" - x509_issuer_serial: X509IssuerSerial = Field(..., alias="RootCertificateID") + x509_issuer_serial: List[X509IssuerSerial] = Field( + ..., max_items=20, alias="RootCertificateID" + ) diff --git a/iso15118/shared/messages/sdp.py b/iso15118/shared/messages/sdp.py index c22b6346..84eff1e4 100644 --- a/iso15118/shared/messages/sdp.py +++ b/iso15118/shared/messages/sdp.py @@ -150,9 +150,9 @@ def __init__( """ TODO: Docstrings - # TODO: We may want to use here the related package or something like pydantic - # which adds some better validations (but also depends if it makes sense given - # the criteria of having a small image) + TODO: We may want to use here the related package or something like pydantic + which adds some better validations (but also depends if it makes sense + given the criteria of having a small image) Also raise Exceptions """ @@ -222,13 +222,12 @@ def __len__(self): return 20 def __repr__(self): + ip_address: str = IPv6Address(int.from_bytes(self.ip_address, "big")).compressed return ( - "[" - + f"IP address: {IPv6Address(int.from_bytes(self.ip_address, 'big')).compressed}" + f"[ IP address: {ip_address}" f", Port: {str(self.port)} " f", Security: {self.security.name} " - f", Transport: {self.transport_protocol.name} " - "]" + f", Transport: {self.transport_protocol.name} ]" ) diff --git a/iso15118/shared/messages/xmldsig.py b/iso15118/shared/messages/xmldsig.py index 43d257cd..c182a87c 100644 --- a/iso15118/shared/messages/xmldsig.py +++ b/iso15118/shared/messages/xmldsig.py @@ -1,32 +1,86 @@ +""" +DataTypes for the construction of the XML signature syntax +Please check: +Section 7.9.2.4.2 XML Signature mechanism from ISO 15118-2 +https://en.wikipedia.org/wiki/XML_Signature +https://www.w3.org/TR/xmldsig-core1/ +""" + from typing import List -from pydantic import Field +from pydantic import Field, HttpUrl from iso15118.shared.messages import BaseModel -class TransformDetails(BaseModel): - algorithm: str = Field(..., alias="Algorithm") +class Transform(BaseModel): + algorithm: HttpUrl = Field(..., alias="Algorithm") -class Transform(BaseModel): - details: TransformDetails = Field(..., alias="Transform") +class Transforms(BaseModel): + """ + According to requirement [V2G2-767], the maximum number of transforms + is limited to one (1), i.e. just one single Transform algorithm can be + indicated. + """ + + transform: List[Transform] = Field(..., max_items=1, alias="Transform") class DigestMethod(BaseModel): - algorithm: str = Field(..., alias="Algorithm") + algorithm: HttpUrl = Field(..., alias="Algorithm") class SignatureMethod(BaseModel): - algorithm: str = Field(..., alias="Algorithm") + algorithm: HttpUrl = Field(..., alias="Algorithm") class CanonicalizationMethod(BaseModel): - algorithm: str = Field(..., alias="Algorithm") + algorithm: HttpUrl = Field(..., alias="Algorithm") class Reference(BaseModel): - transforms: List[Transform] = Field(..., alias="Transforms") + """ + Reference is an object that represents the Reference XML element of a Signature. + This element is a reference to the element of a V2G body message that will + be signed. + + According to xmldisg-core-schema, "Id", "URI" and "Type" all belong to the + Reference complex type, however, according to requirement [V2G2-771], Type + shall not be used. Also, the URI is enough to reference the Id attribute of + the element in the message body. + + In order to understand how Reference is used and the connection to the V2G + body message, the user is invited to check the example in annex J, section + J.2 of the ISO 15118-2, which is partially transcribed here: + + V2G body element contains Id="ID1" + + + U29tZSBSYW5kb20gRGF0YQ== + + + The Signature contains the Reference element with a URI which refers to the + V2G body element (ID1) + + + + + # noqa: E501 + + + + + + 0bXgPQBlvuVrMXmERTBR61TKGPwOCRYXT4s8d6mPSqk= # noqa: E501 + + + + + + """ + + transforms: Transforms = Field(..., alias="Transforms") digest_method: DigestMethod = Field(..., alias="DigestMethod") digest_value: bytes = Field(..., alias="DigestValue") # 'URI' is actually an XML attribute, but JSON (our serialisation method) @@ -35,6 +89,16 @@ class Reference(BaseModel): class SignedInfo(BaseModel): + """ + SignedInfo is an object that belongs to the Signature element as exemplified + in annex J, section J.2 of the ISO 15118-2. + + According to the schema, the Reference attribute is unbounded, however, according + to requirement [V2G2-909]: + "The signature shall not reference more than 4 signed elements" + Therefore, a limit of 4 to the number of items of the `Reference` is enforced. + """ + canonicalization_method: CanonicalizationMethod = Field( ..., alias="CanonicalizationMethod" ) @@ -62,7 +126,7 @@ class X509IssuerSerial(BaseModel): class SignedElement(BaseModel): """ The element of a BodyBase (the message inside the Body element of a - V2GMessage) that needs to be digitally signed and referernced in the + V2GMessage) that needs to be digitally signed and referenced in the SignedInfo field of the header. Example: diff --git a/iso15118/shared/network.py b/iso15118/shared/network.py index db7c31d5..6630fb86 100644 --- a/iso15118/shared/network.py +++ b/iso15118/shared/network.py @@ -27,7 +27,8 @@ def _get_link_local_addr(nic: str) -> Union[IPv6Address, None]: Args: nic_addr_list A list of tuples per network interface card (NIC), each containing e.g. address family and IP address. - More info: https://psutil.readthedocs.io/en/latest/#psutil.net_if_addrs + More info: + https://psutil.readthedocs.io/en/latest/#psutil.net_if_addrs Returns: The IPv6 link-local address from the given list of NIC @@ -64,7 +65,7 @@ async def _get_full_ipv6_address(host: str, port: int) -> Tuple[str, int, int, i In this case we will get one entry that will look like [ (, , 6, '', ('fe80::4fd:9dc8:b138:3bcc', 65334, 0, 5)) ] - Check https://docs.python.org/3/library/asyncio-eventloop.html?highlight=getaddrinfo#asyncio.loop.getaddrinfo + Check https://docs.python.org/3/library/asyncio-eventloop.html?highlight=getaddrinfo#asyncio.loop.getaddrinfo # noqa: E501 loop.getaddrinfo is the async version of socket.getaddrinfo Check https://docs.python.org/3/library/socket.html#socket.getaddrinfo diff --git a/iso15118/shared/security.py b/iso15118/shared/security.py index af204d48..d3c279a3 100644 --- a/iso15118/shared/security.py +++ b/iso15118/shared/security.py @@ -7,7 +7,6 @@ from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm from cryptography.hazmat.backends.openssl.backend import Backend -from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.ec import ( SECP256R1, @@ -15,6 +14,7 @@ EllipticCurvePublicKey, ) from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.hashes import SHA256, Hash, HashAlgorithm from cryptography.hazmat.primitives.kdf.concatkdf import ConcatKDFHash from cryptography.hazmat.primitives.serialization import ( load_der_private_key, @@ -42,17 +42,19 @@ ) from iso15118.shared.exi_codec import to_exi from iso15118.shared.messages.enums import Namespace, Protocol -from iso15118.shared.messages.iso15118_2.datatypes import Certificate as CertificateV2 from iso15118.shared.messages.iso15118_2.datatypes import ( CertificateChain as CertificateChainV2, ) -from iso15118.shared.messages.iso15118_20.common_messages import ( - Certificate as CertificateV20, +from iso15118.shared.messages.iso15118_2.datatypes import ( + SubCertificates as SubCertificatesV2, ) from iso15118.shared.messages.iso15118_20.common_messages import ( CertificateChain as CertificateChainV20, ) from iso15118.shared.messages.iso15118_20.common_messages import SignedCertificateChain +from iso15118.shared.messages.iso15118_20.common_messages import ( + SubCertificates as SubCertificatesV20, +) from iso15118.shared.messages.xmldsig import ( CanonicalizationMethod, DigestMethod, @@ -62,7 +64,7 @@ SignatureValue, SignedInfo, Transform, - TransformDetails, + Transforms, ) from iso15118.shared.settings import PKI_PATH @@ -351,19 +353,21 @@ def load_cert_chain( sub_ca1_cert = load_cert(sub_ca1_path) if sub_ca1_path else None if protocol == Protocol.ISO_15118_2: - sub_ca_certs_v2: List[CertificateV2] = [CertificateV2(certificate=sub_ca2_cert)] + sub_ca_certs_v2: SubCertificatesV2 = SubCertificatesV2( + certificates=[sub_ca2_cert] + ) if sub_ca1_cert: - sub_ca_certs_v2.append(CertificateV2(certificate=sub_ca1_cert)) + sub_ca_certs_v2.certificates.append(sub_ca1_cert) return CertificateChainV2( certificate=leaf_cert, sub_certificates=sub_ca_certs_v2 ) if protocol.ns.startswith(Namespace.ISO_V20_BASE): - sub_ca_certs_v20: List[CertificateV20] = [ - CertificateV20(certificate=sub_ca2_cert) - ] + sub_ca_certs_v20: SubCertificatesV20 = SubCertificatesV20( + certificates=[sub_ca2_cert] + ) if sub_ca1_cert: - sub_ca_certs_v20.append(CertificateV20(certificate=sub_ca1_cert)) + sub_ca_certs_v20.certificates.append(sub_ca1_cert) if id: # In ISO 15118-20, there's a distinction between a CertificateChain @@ -483,7 +487,7 @@ def verify_certs( pub_key.verify( leaf_cert.signature, leaf_cert.tbs_certificate_bytes, - ec.ECDSA(hashes.SHA256()), + ec.ECDSA(SHA256()), ) else: # TODO Add support for ISO 15118-20 public key types @@ -499,7 +503,7 @@ def verify_certs( leaf_cert.signature, leaf_cert.tbs_certificate_bytes, # TODO Find a way to read id dynamically from the certificate - ec.ECDSA(hashes.SHA256()), + ec.ECDSA(SHA256()), ) else: # TODO Add support for ISO 15118-20 public key types @@ -516,7 +520,7 @@ def verify_certs( pub_key.verify( sub_ca2_cert.signature, sub_ca2_cert.tbs_certificate_bytes, - ec.ECDSA(hashes.SHA256()), + ec.ECDSA(SHA256()), ) else: # TODO Add support for ISO 15118-20 public key types @@ -533,7 +537,7 @@ def verify_certs( pub_key.verify( sub_ca1_cert.signature, sub_ca1_cert.tbs_certificate_bytes, - ec.ECDSA(hashes.SHA256()), + ec.ECDSA(SHA256()), ) else: # TODO Add support for ISO 15118-20 public key types @@ -550,7 +554,7 @@ def verify_certs( pub_key.verify( sub_ca2_cert.signature, sub_ca2_cert.tbs_certificate_bytes, - ec.ECDSA(hashes.SHA256()), + ec.ECDSA(SHA256()), ) else: # TODO Add support for ISO 15118-20 public key types @@ -564,13 +568,14 @@ def verify_certs( issuer=cert_to_check.issuer.__str__(), ) from exc except UnsupportedAlgorithm as exc: + cert_hash_algorithm: HashAlgorithm = cert_to_check.signature_hash_algorithm raise CertSignatureError( subject=cert_to_check.subject.__str__(), issuer=cert_to_check.issuer.__str__(), extra_info=f"UnsupportedAlgorithm for certificate " f"{cert_to_check.subject.__str__()}. " f"\nSignature hash algorithm: " - f"{cert_to_check.signature_hash_algorithm.name if cert_to_check.signature_hash_algorithm else 'None'}" + f"{cert_hash_algorithm.name if cert_hash_algorithm else 'None'}" f"\nSignature algorithm: " f"{cert_to_check.signature_algorithm_oid}" # TODO This OpenSSL version may not be the complied one @@ -666,8 +671,11 @@ def create_signature( and then applying ECDSA (Elliptic Curve Digital Signature Algorithm) to it, encrypting with the private key provided. + (Check Annex J, section J.2 in ISO 15118-2, for a reference of how to + generate a signature) + Args: - elements_to_sign: A list of tuples [int, bytes], where the first entry + elements_to_sign: A list of tuples [str, bytes], where the first entry of each tuple is the Id field (XML attribute) and the second entry is the EXI encoded bytes representation of the element for which a Reference element in the @@ -688,17 +696,12 @@ def create_signature( # 1. Step: Reference generation reference_list: List[Reference] = [] - for body_element in elements_to_sign: - id_attr, exi_encoded = body_element + for id_attr, exi_encoded in elements_to_sign: reference = Reference( uri="#" + id_attr, - transforms=[ - Transform( - details=TransformDetails( - algorithm="http://www.w3.org/TR/canonical-exi/" - ) - ) - ], + transforms=Transforms( + transform=[Transform(algorithm="http://www.w3.org/TR/canonical-exi/")] + ), digest_method=DigestMethod( algorithm="http://www.w3.org/2001/04/xmlenc#sha256" ), @@ -719,9 +722,7 @@ def create_signature( # 2. Step: Signature generation exi_encoded_signed_info = to_exi(signed_info, Namespace.XML_DSIG) - signature_value = signature_key.sign( - exi_encoded_signed_info, ec.ECDSA(hashes.SHA256()) - ) + signature_value = signature_key.sign(exi_encoded_signed_info, ec.ECDSA(SHA256())) signature = Signature( signed_info=signed_info, signature_value=SignatureValue(value=signature_value) ) @@ -780,8 +781,7 @@ def verify_signature( True, if the signature can be successfully verified, False otherwise. """ # 1. Step: Digest value check for each reference element - for body_element in elements_to_sign: - id_attr, exi_encoded = body_element + for id_attr, exi_encoded in elements_to_sign: logger.debug(f"Verifying digest for element with ID '{id_attr}'") calculated_digest = create_digest(exi_encoded) message_digests_equal = False @@ -817,7 +817,7 @@ def verify_signature( pub_key.verify( signature.signature_value.value, exi_encoded_signed_info, - ec.ECDSA(hashes.SHA256()), + ec.ECDSA(SHA256()), ) else: # TODO Add support for ISO 15118-20 public key types @@ -857,7 +857,7 @@ def verify_signature( def create_digest(exi_encoded_element) -> bytes: - digest = hashes.Hash(hashes.SHA256()) + digest = Hash(SHA256()) digest.update(exi_encoded_element) return digest.finalize() @@ -962,7 +962,7 @@ def encrypt_priv_key( ) concat_kdf = ConcatKDFHash( - algorithm=hashes.SHA256(), + algorithm=SHA256(), length=symmetric_key_length_in_bytes, otherinfo=other_info, ) @@ -1045,7 +1045,7 @@ def decrypt_priv_key( ) concat_kdf = ConcatKDFHash( - algorithm=hashes.SHA256(), + algorithm=SHA256(), length=symmetric_key_length_in_bytes, otherinfo=other_info, ) @@ -1067,6 +1067,8 @@ class CertPath(str, Enum): Provides the path to certificates used for Plug & Charge. The encoding format is indicated by the latter part of the enum name (_DER or _PEM) + TODO: Make filepath flexible, so we can choose between -2 and -20 certificates + NOTE: For a productive environment, the access to certificate should be managed in a secure way (e.g. through a hardware security module). """ diff --git a/iso15118/shared/states.py b/iso15118/shared/states.py index c2789402..9bb19776 100644 --- a/iso15118/shared/states.py +++ b/iso15118/shared/states.py @@ -260,7 +260,7 @@ def create_next_message( try: exi_payload = to_exi(to_be_exi_encoded, namespace) except EXIEncodingError as exc: - logger.error(f"{exc.error}") + logger.error(f"{exc}") self.next_state = Terminate raise diff --git a/iso15118/shared/utils.py b/iso15118/shared/utils.py index 1762f8b1..78d4c2b1 100644 --- a/iso15118/shared/utils.py +++ b/iso15118/shared/utils.py @@ -59,7 +59,7 @@ async def wait_till_finished( # As of Python 3.8 `asyncio.wait()` should be called only with # `asyncio.Task`s. - # See: https://docs.python.org/3/library/asyncio-task.html#asyncio-example-wait-coroutine + # See: https://docs.python.org/3/library/asyncio-task.html#asyncio-example-wait-coroutine # noqa: E501 for awaitable in awaitables: if not isinstance(awaitable, asyncio.Task): awaitable = asyncio.create_task(awaitable) diff --git a/pyproject.toml b/pyproject.toml index dac82cfa..37b15ede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,9 @@ isort = "^5.10.1" flake8 = "^4.0.1" pytest-cov = "^3.0.0" +[tool.isort] +profile = "black" + [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/template.Dockerfile b/template.Dockerfile index b4907064..f779bd0d 100644 --- a/template.Dockerfile +++ b/template.Dockerfile @@ -11,7 +11,7 @@ ENV PYTHONFAULTHANDLER=1 \ PIP_DEFAULT_TIMEOUT=100 \ POETRY_VERSION=1.1.11 \ VIRTUALENV_PIP=21.2.1 \ - MYPY_VERSION=0.910 + MYPY_VERSION=0.930 RUN pip install "poetry==$POETRY_VERSION" "mypy==$MYPY_VERSION" @@ -38,8 +38,9 @@ COPY iso15118/ iso15118/ COPY tests/ tests/ RUN poetry run pytest -vv --cov-config .coveragerc --cov-report term-missing --durations=3 --cov=. RUN poetry run black --check --diff --line-length=88 iso15118 tests -#RUN flake8 --config .flake8 iso15118 tests -#RUN poetry run mypy --config-file mypy.ini cs run.py tests +RUN poetry run flake8 --config .flake8 iso15118 tests +# RUN poetry run mypy --config-file mypy.ini iso15118 tests + # Generate the wheel to be used by next stage RUN poetry build