diff --git a/.env.dev.local b/.env.dev.local index cacac1d6..3590c118 100644 --- a/.env.dev.local +++ b/.env.dev.local @@ -1,10 +1,10 @@ # General Settings -NETWORK_INTERFACE=eth0 +NETWORK_INTERFACE=en0 +# Redis REDIS_PORT=10001 REDIS_HOST=localhost - # SECC Settings SECC_CONTROLLER_SIM=True FREE_CHARGING_SERVICE=False diff --git a/iso15118/evcc/controller/interface.py b/iso15118/evcc/controller/interface.py index a7121d44..a811d2f2 100644 --- a/iso15118/evcc/controller/interface.py +++ b/iso15118/evcc/controller/interface.py @@ -13,13 +13,33 @@ ChargingProfile, DCEVChargeParameter, EnergyTransferModeEnum, + SAScheduleTuple, SAScheduleTupleEntry, + ProfileEntry, ) from iso15118.shared.messages.iso15118_20.ac import ( ACChargeParameterDiscoveryReqParams, BPTACChargeParameterDiscoveryReqParams, ) from iso15118.shared.messages.iso15118_20.common_messages import EMAIDList +from iso15118.shared.messages.enums import Protocol, ServiceV20 + +from iso15118.shared.messages.iso15118_20.ac import ( + ACChargeParameterDiscoveryReqParams, + BPTACChargeParameterDiscoveryReqParams, +) +from iso15118.shared.messages.iso15118_20.common_messages import ( + EMAID, + ParameterSet as ParameterSetV20, + ScheduledScheduleExchangeReqParams, + DynamicScheduleExchangeReqParams, + SelectedEnergyService, + SelectedVAS, +) +from iso15118.shared.messages.iso15118_20.dc import ( + DCChargeParameterDiscoveryReqParams, + BPTDCChargeParameterDiscoveryReqParams, +) @dataclass @@ -30,6 +50,11 @@ class ChargeParamsV2: class EVControllerInterface(ABC): + + # ============================================================================ + # | COMMON FUNCTIONS (FOR ALL ENERGY TRANSFER MODES) | + # ============================================================================ + @abstractmethod def get_evcc_id(self, protocol: Protocol, iface: str) -> str: """ @@ -65,6 +90,63 @@ def get_energy_transfer_mode(self) -> EnergyTransferModeEnum: """ raise NotImplementedError + @abstractmethod + def get_energy_service(self) -> ServiceV20: + """ + Gets the energy transfer service requested for the current charging session. + This must be one of the energy related services (services with ID 1 through 7) + + Relevant for: + - ISO 15118-20 + """ + raise NotImplementedError + + @abstractmethod + def select_energy_service_v20( + self, service: ServiceV20, is_free: bool, parameter_sets: List[ParameterSetV20] + ) -> SelectedEnergyService: + """ + Selects the energy service and associated parameter set from a given set of + parameters per energy service ID. + + Args: + service: The service given as an enum member of ServiceV20 + is_free: Whether this is a free or paid service + parameter_sets: The parameter sets, which the SECC offers for that energy + service + + Returns: + An instance of SelectedEnergyService, containing the service, whether it's + free or paid, and its chosen parameter set. + + Relevant for: + - ISO 15118-20 + """ + raise NotImplementedError + + @abstractmethod + def select_vas_v20( + self, service: ServiceV20, is_free: bool, parameter_sets: List[ParameterSetV20] + ) -> Optional[SelectedVAS]: + """ + Selects a value-added service (VAS) and associated parameter set from a given + set of parameters for that value-added energy. If you don't want to select + the offered VAS, return None. + + Args: + service: The value-added service given as an enum member of ServiceV20 + is_free: Whether this is a free or paid service + parameter_sets: The parameter sets, which the SECC offers for that VAS + + Returns: + An instance of SelectedVAS, containing the service, whether it's free or + paid, and its chosen parameter set. + + Relevant for: + - ISO 15118-20 + """ + raise NotImplementedError + @abstractmethod def get_charge_params_v2(self) -> ChargeParamsV2: """ @@ -84,27 +166,74 @@ def get_charge_params_v2(self) -> ChargeParamsV2: @abstractmethod def get_charge_params_v20( - self, - ) -> Tuple[ + self, selected_service: SelectedEnergyService + ) -> Union[ ACChargeParameterDiscoveryReqParams, - Optional[BPTACChargeParameterDiscoveryReqParams], + BPTACChargeParameterDiscoveryReqParams, + DCChargeParameterDiscoveryReqParams, + BPTDCChargeParameterDiscoveryReqParams, ]: """ - Gets the charge parameters needed for a ChargeParameterDiscoveryReq, which is - either an ACChargeParameterDiscoveryReq, or a DCChargeParameterDiscoveryReq, - or a WPTChargeParameterDiscoveryReq from ISO 15118-20, including the optional - optional BPT (bi-directional power flow) paramters. + Gets the charge parameters needed for a ChargeParameterDiscoveryReq. + + Args: + selected_service: The energy transfer service, which the EVCC selected, and + for which we need the EVCC's charge parameters. This is + an instance of the custom class SelectedEnergyService. Returns: - A tuple containing both the non-BPT and BPT charger parameter needed for a - request message of type ChargeParameterDiscoveryReq (AC, DC, or WPT) + Charge parameters for either unidirectional or bi-directional power + transfer needed for a ChargeParameterDiscoveryReq. Relevant for: - ISO 15118-20 - TODO Add support for DC and WPT in the return type + + TODO Add support for WPT and ACDP in the return type """ raise NotImplementedError + @abstractmethod + def get_scheduled_se_params( + self, selected_energy_service: SelectedEnergyService + ) -> ScheduledScheduleExchangeReqParams: + """ + Gets the parameters for a ScheduleExchangeRequest, which correspond to the + Scheduled control mode. + + Args: + selected_energy_service: The energy services, which the EVCC selected. + The selected parameter set, that is associated + with that energy service, influences the + parameters for the ScheduleExchangeReq + + Returns: + Parameters for the ScheduleExchangeReq in Scheduled control mode + + Relevant for: + - ISO 15118-20 + """ + + @abstractmethod + def get_dynamic_se_params( + self, selected_energy_service: SelectedEnergyService + ) -> DynamicScheduleExchangeReqParams: + """ + Gets the parameters for a ScheduleExchangeRequest, which correspond to the + Dynamic control mode. + + Args: + selected_energy_service: The energy services, which the EVCC selected. + The selected parameter set, that is associated + with that energy service, influences the + parameters for the ScheduleExchangeReq + + Returns: + Parameters for the ScheduleExchangeReq in Dynamic control mode + + Relevant for: + - ISO 15118-20 + """ + @abstractmethod def is_cert_install_needed(self) -> bool: """ @@ -200,3 +329,11 @@ def get_prioritised_emaids(self) -> Optional[EMAIDList]: - ISO 15118-20 """ raise NotImplementedError + + # ============================================================================ + # | AC-SPECIFIC FUNCTIONS | + # ============================================================================ + + # ============================================================================ + # | DC-SPECIFIC FUNCTIONS | + # ============================================================================ diff --git a/iso15118/evcc/controller/simulator.py b/iso15118/evcc/controller/simulator.py index 5bf54345..4fa3c256 100644 --- a/iso15118/evcc/controller/simulator.py +++ b/iso15118/evcc/controller/simulator.py @@ -28,7 +28,37 @@ BPTACChargeParameterDiscoveryReqParams, ) from iso15118.shared.messages.iso15118_20.common_messages import EMAIDList +from iso15118.shared.messages.enums import ( + Namespace, + Protocol, + ServiceV20, + PriceAlgorithm, +) +from iso15118.shared.messages.iso15118_20.ac import ( + ACChargeParameterDiscoveryReqParams, + BPTACChargeParameterDiscoveryReqParams, +) +from iso15118.shared.messages.iso15118_20.common_messages import ( + EMAID, + ParameterSet as ParameterSetV20, + ScheduledScheduleExchangeReqParams, + DynamicScheduleExchangeReqParams, + EVEnergyOffer, + EVPowerSchedule, + EVPowerScheduleEntryList, + EVAbsolutePriceSchedule, + EVPowerScheduleEntry, + EVPriceRuleStackList, + EVPriceRuleStack, + EVPriceRule, + SelectedEnergyService, + SelectedVAS, +) from iso15118.shared.messages.iso15118_20.common_types import RationalNumber +from iso15118.shared.messages.iso15118_20.dc import ( + DCChargeParameterDiscoveryReqParams, + BPTDCChargeParameterDiscoveryReqParams, +) from iso15118.shared.network import get_nic_mac_address logger = logging.getLogger(__name__) @@ -42,6 +72,11 @@ class SimEVController(EVControllerInterface): def __init__(self): self.charging_loop_cycles: int = 0 + # ============================================================================ + # | COMMON FUNCTIONS (FOR ALL ENERGY TRANSFER MODES) | + # ============================================================================ + + def get_evcc_id(self, protocol: Protocol, iface: str) -> str: def get_evcc_id(self, protocol: Protocol, iface: str) -> str: """Overrides EVControllerInterface.get_evcc_id().""" @@ -67,6 +102,32 @@ def get_energy_transfer_mode(self) -> EnergyTransferModeEnum: """Overrides EVControllerInterface.get_energy_transfer_mode().""" return EnergyTransferModeEnum.AC_THREE_PHASE_CORE + def get_energy_service(self) -> ServiceV20: + """Overrides EVControllerInterface.get_energy_transfer_service().""" + return ServiceV20.DC + + def select_energy_service_v20( + self, service: ServiceV20, is_free: bool, parameter_sets: List[ParameterSetV20] + ) -> SelectedEnergyService: + """Overrides EVControllerInterface.select_energy_service_v20().""" + selected_service = SelectedEnergyService( + service=ServiceV20.get_by_id(service.service_id), + is_free=is_free, + parameter_set=parameter_sets.pop(), + ) + return selected_service + + def select_vas_v20( + self, service: ServiceV20, is_free: bool, parameter_sets: List[ParameterSetV20] + ) -> Optional[SelectedVAS]: + """Overrides EVControllerInterface.select_vas_v20().""" + selected_service = SelectedVAS( + service=ServiceV20.get_by_id(service.service_id), + is_free=is_free, + parameter_set=parameter_sets.pop(), + ) + return selected_service + def get_charge_params_v2(self) -> ChargeParamsV2: """Overrides EVControllerInterface.get_charge_params_v2().""" # This is for simulating AC only. You can modify to simulate DC charging @@ -88,30 +149,125 @@ def get_charge_params_v2(self) -> ChargeParamsV2: return ChargeParamsV2(self.get_energy_transfer_mode(), ac_charge_params, None) def get_charge_params_v20( - self, - ) -> Tuple[ + self, selected_service: SelectedEnergyService + ) -> Union[ ACChargeParameterDiscoveryReqParams, - Optional[BPTACChargeParameterDiscoveryReqParams], + BPTACChargeParameterDiscoveryReqParams, + DCChargeParameterDiscoveryReqParams, + BPTDCChargeParameterDiscoveryReqParams, ]: """Overrides EVControllerInterface.get_charge_params_v20().""" - ac_params = ACChargeParameterDiscoveryReqParams( - ev_max_charge_power=RationalNumber(exponent=1, value=11), - ev_min_charge_power=RationalNumber(exponent=0, value=10), + if selected_service.service == ServiceV20.AC: + return ACChargeParameterDiscoveryReqParams( + ev_max_charge_power=RationalNumber(exponent=3, value=11), + ev_min_charge_power=RationalNumber(exponent=0, value=100), + ) + elif selected_service.service == ServiceV20.AC_BPT: + return BPTACChargeParameterDiscoveryReqParams( + ev_max_charge_power=RationalNumber(exponent=3, value=11), + ev_min_charge_power=RationalNumber(exponent=0, value=100), + ev_max_discharge_power=RationalNumber(exponent=3, value=11), + ev_min_discharge_power=RationalNumber(exponent=0, value=100), + ) + elif selected_service.service == ServiceV20.DC: + return DCChargeParameterDiscoveryReqParams( + ev_max_charge_power=RationalNumber(exponent=3, value=300), + ev_min_charge_power=RationalNumber(exponent=0, value=100), + ev_max_charge_current=RationalNumber(exponent=0, value=300), + ev_min_charge_current=RationalNumber(exponent=0, value=10), + ev_max_voltage=RationalNumber(exponent=0, value=1000), + ev_min_voltage=RationalNumber(exponent=0, value=10), + ) + elif selected_service.service == ServiceV20.DC_BPT: + return BPTDCChargeParameterDiscoveryReqParams( + ev_max_charge_power=RationalNumber(exponent=3, value=300), + ev_min_charge_power=RationalNumber(exponent=0, value=100), + ev_max_charge_current=RationalNumber(exponent=0, value=300), + ev_min_charge_current=RationalNumber(exponent=0, value=10), + ev_max_voltage=RationalNumber(exponent=0, value=1000), + ev_min_oltage=RationalNumber(exponent=0, value=10), + ev_max_discharge_power=RationalNumber(exponent=3, value=11), + ev_min_discharge_power=RationalNumber(exponent=3, value=1), + ev_max_discharge_current=RationalNumber(exponent=0, value=11), + ev_min_discharge_current=RationalNumber(exponent=0, value=0), + ) + else: + # TODO Implement the remaining energy transer services + logger.error( + f"Energy transfer service {selected_service.service} not supported" + ) + + def get_scheduled_se_params( + self, selected_energy_service: SelectedEnergyService + ) -> ScheduledScheduleExchangeReqParams: + """Overrides EVControllerInterface.get_scheduled_se_params().""" + ev_price_rule = EVPriceRule( + energy_fee=RationalNumber(exponent=0, value=0), + power_range_start=RationalNumber(exponent=0, value=0), + ) + + ev_price_rule_stack = EVPriceRuleStack( + duration=0, ev_price_rules=[ev_price_rule] + ) + + ev_price_rule_stack_list = EVPriceRuleStackList( + ev_price_rule_stacks=[ev_price_rule_stack] + ) + + ev_absolute_price_schedule = EVAbsolutePriceSchedule( + time_anchor=0, + currency="EUR", + price_algorithm=PriceAlgorithm.POWER, + ev_price_rule_stacks=ev_price_rule_stack_list, + ) + + ev_power_schedule_entry = EVPowerScheduleEntry( + duration=3600, power=RationalNumber(exponent=3, value=-10) + ) + + ev_power_schedule_entries = EVPowerScheduleEntryList( + ev_power_schedule_entries=[ev_power_schedule_entry] + ) + + ev_power_schedule = EVPowerSchedule( + time_anchor=0, ev_power_schedule_entries=ev_power_schedule_entries + ) + + energy_offer = EVEnergyOffer( + ev_power_schedule=ev_power_schedule, + ev_absolute_price_schedule=ev_absolute_price_schedule, ) - bpt_ac_params = BPTACChargeParameterDiscoveryReqParams( - ev_max_charge_power=RationalNumber(exponent=1, value=11), - ev_min_charge_power=RationalNumber(exponent=0, value=10), - ev_max_discharge_power=RationalNumber(exponent=1, value=11), - ev_min_discharge_power=RationalNumber(exponent=0, value=10), + scheduled_params = ScheduledScheduleExchangeReqParams( + departure_time=7200, + ev_target_energy_request=RationalNumber(exponent=3, value=10), + ev_max_energy_request=RationalNumber(exponent=3, value=20), + ev_min_energy_request=RationalNumber(exponent=3, value=5), + energy_offer=energy_offer, ) - # TODO Add support for DC and WPT - return ac_params, bpt_ac_params + return scheduled_params + + def get_dynamic_se_params( + self, selected_energy_service: SelectedEnergyService + ) -> DynamicScheduleExchangeReqParams: + """Overrides EVControllerInterface.get_dynamic_se_params().""" + dynamic_params = DynamicScheduleExchangeReqParams( + departure_time=7200, + min_soc=30, + target_soc=80, + ev_target_energy_request=RationalNumber(exponent=3, value=40), + ev_max_energy_request=RationalNumber(exponent=1, value=6000), + ev_min_energy_request=RationalNumber(exponent=0, value=20000), + ev_max_v2x_energy_request=RationalNumber(exponent=0, value=5000), + ev_min_v2x_energy_request=RationalNumber(exponent=0, value=0), + ) + + return dynamic_params def is_cert_install_needed(self) -> bool: """Overrides EVControllerInterface.is_cert_install_needed().""" - return True + return False def process_sa_schedules( self, sa_schedules: List[SAScheduleTupleEntry] @@ -174,3 +330,11 @@ def store_contract_cert_and_priv_key(self, contract_cert: bytes, priv_key: bytes def get_prioritised_emaids(self) -> Optional[EMAIDList]: return None + + # ============================================================================ + # | AC-SPECIFIC FUNCTIONS | + # ============================================================================ + + # ============================================================================ + # | DC-SPECIFIC FUNCTIONS | + # ============================================================================ diff --git a/iso15118/evcc/evcc_settings.py b/iso15118/evcc/evcc_settings.py index 25578214..37485aa2 100644 --- a/iso15118/evcc/evcc_settings.py +++ b/iso15118/evcc/evcc_settings.py @@ -8,7 +8,7 @@ from iso15118.evcc.controller.interface import EVControllerInterface from iso15118.evcc.controller.simulator import SimEVController -from iso15118.shared.messages.enums import Protocol +from iso15118.shared.messages.enums import Protocol, INT_16_MAX from iso15118.shared.network import validate_nic logger = logging.getLogger(__name__) @@ -26,12 +26,14 @@ class Config: use_tls: bool = True enforce_tls: bool = False supported_protocols: Optional[List[Protocol]] = None - + max_supporting_points: Optional[int] = None + def load_envs(self, env_path: Optional[str] = None) -> None: """ Tries to load the .env file containing all the project settings. If `env_path` is not specified, it will get the .env on the current working directory of the project + Args: env_path (str): Absolute path to the location of the .env file """ @@ -50,8 +52,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 + # 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 @@ -67,7 +69,7 @@ def load_envs(self, env_path: Optional[str] = None) -> None: # the EV can store. That value is used in the CertificateInstallationReq. # Must be an integer between 0 and 65535, should be bigger than 0. self.max_contract_certs = env.int( - "MAX_CONTRACT_CERTS", default=3, validate=Range(min=1, max=65535) + "MAX_CONTRACT_CERTS", default=3, validate=Range(min=1, max=INT_16_MAX) ) # Indicates the security level (either TCP (unencrypted) or TLS (encrypted)) @@ -89,6 +91,14 @@ def load_envs(self, env_path: Optional[str] = None) -> None: # of the Protocol enum self.supported_protocols = [Protocol.ISO_15118_2, Protocol.ISO_15118_20_AC] + # Indicates the maximum number of entries the EVCC supports within the + # sub-elements of a ScheduleTuple (e.g. PowerScheduleType and PriceRuleType in + # ISO 15118-20 as well as PMaxSchedule and SalesTariff in ISO 15118-2). + # The SECC must not transmit more entries than defined in this parameter. + self.max_supporting_points = env.int( + "MAX_SUPPORTING_POINTS", default=1024, validate=Range(min=0, max=1024) + ) + env.seal() # raise all errors at once, if any diff --git a/iso15118/evcc/states/iso15118_20_states.py b/iso15118/evcc/states/iso15118_20_states.py index 111444e8..c52b65e2 100644 --- a/iso15118/evcc/states/iso15118_20_states.py +++ b/iso15118/evcc/states/iso15118_20_states.py @@ -6,7 +6,7 @@ import logging import time -from typing import Union +from typing import Union, List from iso15118.evcc.comm_session_handler import EVCCCommunicationSession from iso15118.evcc.states.evcc_state import StateEVCC @@ -16,7 +16,14 @@ SupportedAppProtocolReq, SupportedAppProtocolRes, ) -from iso15118.shared.messages.enums import AuthEnum, Namespace +from iso15118.shared.messages.enums import ( + AuthEnum, + Namespace, + ServiceV20, + ISOV20PayloadTypes, + ParameterName, + ControlMode, +) from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 from iso15118.shared.messages.iso15118_20.common_messages import ( AuthorizationReq, @@ -26,14 +33,30 @@ EIMAuthReqParams, PnCAuthReqParams, SessionSetupRes, + AuthorizationRes, + ServiceDiscoveryReq, + ServiceDiscoveryRes, + ServiceDetailReq, + SessionStopReq, + ChargingSession, + ServiceDetailRes, + ServiceSelectionReq, + SelectedService, + ServiceSelectionRes, + ScheduleExchangeReq, + OfferedService, ) from iso15118.shared.messages.iso15118_20.common_types import ( MessageHeader, - RootCertificateID, + RootCertificateIDList, Processing, ) from iso15118.shared.messages.iso15118_20.common_types import ( V2GMessage as V2GMessageV20, ) +from iso15118.shared.messages.iso15118_20.dc import ( + DCChargeParameterDiscoveryReq, + DCChargeParameterDiscoveryRes, +) from iso15118.shared.messages.iso15118_20.timeouts import Timeouts from iso15118.shared.messages.xmldsig import X509IssuerSerial from iso15118.shared.security import ( @@ -94,6 +117,7 @@ def process_message( auth_setup_req, Timeouts.AUTHORIZATION_SETUP_REQ, Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ) @@ -126,7 +150,7 @@ def process_message( auth_setup_res.cert_install_service and self.comm_session.ev_controller.is_cert_install_needed() ): - # TODO: Find a more generic way to serach for all available + # TODO: Find a more generic way to search for all available # V2GRootCA certificates issuer, serial = get_cert_issuer_serial(CertPath.V2G_ROOT_DER) @@ -158,15 +182,17 @@ def process_message( signature=signature, ), oem_prov_cert_chain=oem_prov_cert_chain, - list_of_root_cert_ids=[ - RootCertificateID( - x509_issuer_serial=X509IssuerSerial( + root_cert_id_list=RootCertificateIDList( + root_cert_id=[ + X509IssuerSerial( x509_issuer_name=issuer, x509_serial_number=serial ) - ) - ], - 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 + ] + ), + max_contract_cert_chains= + self.comm_session.config.max_contract_certs, + prioritized_emaids= + self.comm_session.ev_controller.get_prioritised_emaids(), ) self.create_next_message( @@ -175,77 +201,395 @@ def process_message( Timeouts.CERTIFICATE_INSTALLATION_REQ, Namespace.ISO_V20_COMMON_MSG, ) + return except PrivateKeyReadError as exc: - self.stop_state_machine( - "Can't read private key necessary to sign " - f"CertificateInstallationReq: {exc}" + logger.warning( + "PrivateKeyReadError occurred while trying to create " + "signature for CertificateInstallationReq. Falling back to sending " + f"AuthorizationReq instead.\n{exc}" ) - return + eim_params, pnc_params = None, None + if AuthEnum.PNC in auth_setup_res.auth_services: + # TODO Check if several contract certificates are in place and + # if the SECC sent a list of supported providers to pre- + # select the contract certificate(s) that work at this SECC + pnc_params = PnCAuthReqParams( + gen_challenge=auth_setup_res.pnc_as_res.gen_challenge, + contract_cert_chain=load_cert_chain( + protocol=self.comm_session.protocol, + leaf_path=CertPath.CONTRACT_LEAF_DER, + sub_ca2_path=CertPath.MO_SUB_CA2_DER, + sub_ca1_path=CertPath.MO_SUB_CA1_DER, + ), + id="id1", + ) + + # TODO Need a signature for ISO 15118-20, not ISO 15118-2 + try: + signature = create_signature( + [ + ( + pnc_params.id, + to_exi(pnc_params, Namespace.ISO_V20_COMMON_MSG), + ) + ], + load_priv_key(KeyPath.CONTRACT_LEAF_PEM, KeyEncoding.PEM), + ) + except PrivateKeyReadError as exc: + logger.warning( + "PrivateKeyReadError occurred while trying to create " + "signature for PnC_AReqAuthorizationMode. Falling back to EIM " + f"identification mode.\n{exc}" + ) + pnc_params = None + eim_params = EIMAuthReqParams() else: - eim_params, pnc_params = None, None - if AuthEnum.PNC in auth_setup_res.auth_services: - # TODO Check if several contract certificates are in place and - # if the SECC sent a list of supported providers to pre- - # select the contract certificate(s) that work at this SECC - pnc_params = PnCAuthReqParams( - gen_challenge=auth_setup_res.pnc_as_res.gen_challenge, - contract_cert_chain=load_cert_chain( - protocol=self.comm_session.protocol, - leaf_path=CertPath.CONTRACT_LEAF_DER, - sub_ca2_path=CertPath.MO_SUB_CA2_DER, - sub_ca1_path=CertPath.MO_SUB_CA1_DER, - ), - id="id1", + eim_params = EIMAuthReqParams() + + auth_req = AuthorizationReq( + header=MessageHeader( + session_id=self.comm_session.session_id, + timestamp=time.time(), + signature=signature, + ), + selected_auth_service=AuthEnum.PNC if pnc_params else AuthEnum.EIM, + pnc_params=pnc_params, + eim_params=eim_params, + ) + + self.create_next_message( + Authorization, + auth_req, + Timeouts.AUTHORIZATION_REQ, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, + ) + + +class CertificateInstallation(StateEVCC): + """ + The ISO 15118-20 state in which the EVCC processes a + CertificateInstallationRes from the SECC. + """ + + def __init__(self, comm_session: EVCCCommunicationSession): + super().__init__(comm_session, Timeouts.CERTIFICATE_INSTALLATION_REQ) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + raise NotImplementedError("CertificateInstallation not yet implemented") + + +class Authorization(StateEVCC): + """ + The ISO 15118-20 state in which the EVCC processes an AuthorizationRes + from the SECC. + """ + + def __init__(self, comm_session: EVCCCommunicationSession): + super().__init__(comm_session, Timeouts.AUTHORIZATION_REQ) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + msg = self.check_msg_v20(message, AuthorizationRes) + if not msg: + return + + auth_res: AuthorizationRes = msg + # TODO Act upon the response codes and evse_processing value of auth_res + # TODO: V2G20-2221 demands to send CertificateInstallationReq if necessary + + service_discovery_req = ServiceDiscoveryReq( + header=MessageHeader( + session_id=self.comm_session.session_id, + timestamp=time.time(), + ) + # To limit the list of requested VAS services, set supported_service_ids + ) + + self.create_next_message( + ServiceDiscovery, + service_discovery_req, + Timeouts.SERVICE_DISCOVERY_REQ, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, + ) + + +class ServiceDiscovery(StateEVCC): + """ + The ISO 15118-20 state in which the EVCC processes a ServiceDiscoveryRes + from the SECC. + """ + + def __init__(self, comm_session: EVCCCommunicationSession): + super().__init__(comm_session, Timeouts.SERVICE_DISCOVERY_REQ) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + msg = self.check_msg_v20(message, ServiceDiscoveryRes) + if not msg: + return + + service_discovery_res: ServiceDiscoveryRes = msg + + self.comm_session.service_renegotiation_supported = ( + service_discovery_res.service_renegotiation_supported + ) + + req_energy_service: ServiceV20 = ( + self.comm_session.ev_controller.get_energy_service() + ) + + matched_energy_service: bool = False + for energy_service in service_discovery_res.energy_service_list.services: + self.comm_session.offered_services_v20.append( + OfferedService( + service=ServiceV20.get_by_id(energy_service.service_id), + is_energy_service=True, + is_free=energy_service.free_service, + # Parameter sets are available with ServiceDetailRes + parameter_sets=[], ) + ) - # TODO Need a signature for ISO 15118-20, not ISO 15118-2 - try: - signature = create_signature( - [ - ( - pnc_params.id, - to_exi(pnc_params, Namespace.ISO_V20_COMMON_MSG), - ) - ], - load_priv_key(KeyPath.OEM_LEAF_PEM, KeyEncoding.PEM), - ) - except PrivateKeyReadError as exc: - self.stop_state_machine( - "Can't read private key necessary to sign " - f"AuthorizationReq: {exc}" + if req_energy_service == ServiceV20.get_by_id(energy_service.service_id): + matched_energy_service = True + self.comm_session.service_details_to_request.append( + energy_service.service_id + ) + + if not matched_energy_service: + session_stop_req = SessionStopReq( + header=MessageHeader( + session_id=self.comm_session.session_id, + timestamp=time.time(), + ), + charging_session=ChargingSession.TERMINATE, + # See "3.5.2. Error handling" in CharIN Implementation Guide for DC BPT + ev_termination_code=1, + ev_termination_explanation="WrongServiceID", + ) + + self.create_next_message( + SessionStop, + session_stop_req, + Timeouts.SESSION_STOP_REQ, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, + ) + return + + if service_discovery_res.vas_list: + for vas_service in service_discovery_res.vas_list.services: + self.comm_session.offered_services_v20.append( + OfferedService( + service=ServiceV20.get_by_id(vas_service.service_id), + is_energy_service=False, + is_free=vas_service.free_service, + # Parameter sets are available with ServiceDetailRes + parameter_sets=[], ) - return - else: - eim_params = EIMAuthReqParams() + ) + + # If you want to request service details for a specific value-added + # service, then use these lines of code: + # self.comm_session.service_details_to_request.append( + # vas_service.service_id + # ) + + service_detail_req = ServiceDetailReq( + header=MessageHeader( + session_id=self.comm_session.session_id, + timestamp=time.time(), + ), + service_id=self.comm_session.service_details_to_request.pop(), + ) - auth_req = AuthorizationReq( + self.create_next_message( + ServiceDetail, + service_detail_req, + Timeouts.SERVICE_DETAIL_REQ, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, + ) + + +class ServiceDetail(StateEVCC): + """ + The ISO 15118-20 state in which the EVCC processes a ServiceDetailRes + from the SECC. + """ + + def __init__(self, comm_session: EVCCCommunicationSession): + super().__init__(comm_session, Timeouts.SERVICE_DETAIL_REQ) + # Checks whether a control mode for the selected energy service was provided. + # Should always be the case and is needed to distinguish between Scheduled and + # Dynamic mode for the further messages. + self.control_mode_found = False + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + msg = self.check_msg_v20(message, ServiceDetailRes) + if not msg: + return + + service_detail_res: ServiceDetailRes = msg + + self.select_services(service_detail_res) + + if not self.control_mode_found: + session_stop_req = SessionStopReq( header=MessageHeader( session_id=self.comm_session.session_id, timestamp=time.time(), - signature=signature, ), - selected_auth_service=AuthEnum.PNC if pnc_params else AuthEnum.EIM, - pnc_params=pnc_params, - eim_params=eim_params, + charging_session=ChargingSession.TERMINATE, + ev_termination_explanation="Control mode parameter missing", ) self.create_next_message( - Authorization, - auth_req, - Timeouts.AUTHORIZATION_REQ, + SessionStop, + session_stop_req, + Timeouts.SESSION_STOP_REQ, Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ) + return + if len(self.comm_session.service_details_to_request) > 0: + service_detail_req = ServiceDetailReq( + header=MessageHeader( + session_id=self.comm_session.session_id, + timestamp=time.time(), + ), + service_id=self.comm_session.service_details_to_request.pop(), + ) -class CertificateInstallation(StateEVCC): + self.create_next_message( + ServiceDetail, + service_detail_req, + Timeouts.SERVICE_DETAIL_REQ, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, + ) + + return + + selected_vas_list: List[SelectedService] = [] + for vas in self.comm_session.selected_vas_list_v20: + selected_vas_list.append( + SelectedService( + service_id=vas.service.id, parameter_set_id=vas.parameter_set.id + ) + ) + + selected_energy_service = SelectedService( + service_id=self.comm_session.selected_energy_service.service.id, + parameter_set_id=self.comm_session.selected_energy_service.parameter_set.id, + ) + + service_selection_req = ServiceSelectionReq( + header=MessageHeader( + session_id=self.comm_session.session_id, + timestamp=time.time(), + ), + selected_energy_service=selected_energy_service, + selected_vas_list=selected_vas_list if len(selected_vas_list) > 0 else None, + ) + + self.create_next_message( + ServiceSelection, + service_selection_req, + Timeouts.SERVICE_SELECTION_REQ, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, + ) + + def select_services(self, service_detail_res: ServiceDetailRes): + requested_energy_service: ServiceV20 = ( + self.comm_session.ev_controller.get_energy_service() + ) + + for offered_service in self.comm_session.offered_services_v20: + # Safe the parameter sets for a particular service + if offered_service.service.id == service_detail_res.service_id: + offered_service.parameter_sets = ( + service_detail_res.service_parameter_list.parameter_sets + ) + + # Select the energy service and the corresponding parameter set if the + # offered energy service is the one the EVCC requested + if ( + offered_service.is_energy_service + and offered_service.service == requested_energy_service + ): + self.comm_session.selected_energy_service = ( + self.comm_session.ev_controller.select_energy_service_v20( + offered_service.service, + offered_service.is_free, + offered_service.parameter_sets, + ) + ) + + param_set = self.comm_session.selected_energy_service.parameter_set + for param in param_set.parameters: + if param.name == ParameterName.CONTROL_MODE: + self.comm_session.control_mode = ControlMode( + param.int_value + ) + self.control_mode_found = True + + # Select the value-added service (VAS) and corresponding parameter set + # if you want to use that service + if not offered_service.is_energy_service: + selected_vas = self.comm_session.ev_controller.select_vas_v20( + offered_service.service, + offered_service.is_free, + offered_service.parameter_sets, + ) + + if selected_vas: + self.comm_session.selected_vas_list_v20.append(selected_vas) + + +class ServiceSelection(StateEVCC): """ - The ISO 15118-20 state in which the EVCC processes a - CertificateInstallationRes from the SECC. + The ISO 15118-20 state in which the EVCC processes a ServiceSelectionRes + from the SECC. """ def __init__(self, comm_session: EVCCCommunicationSession): - super().__init__(comm_session, Timeouts.CERTIFICATE_INSTALLATION_REQ) + super().__init__(comm_session, Timeouts.SERVICE_SELECTION_REQ) def process_message( self, @@ -256,17 +600,66 @@ def process_message( V2GMessageV20, ], ): - raise NotImplementedError("CertificateInstallation not yet implemented") + msg = self.check_msg_v20(message, ServiceSelectionRes) + if not msg: + return + service_selection_res: ServiceSelectionRes = msg + # TODO Act upon the possible negative response codes in service_selection_res -class Authorization(StateEVCC): + charge_params = self.comm_session.ev_controller.get_charge_params_v20( + self.comm_session.selected_energy_service + ) + + if self.comm_session.selected_energy_service.service == ServiceV20.DC: + next_req = DCChargeParameterDiscoveryReq( + header=MessageHeader( + session_id=self.comm_session.session_id, + timestamp=time.time(), + ), + dc_params=charge_params, + ) + + self.create_next_message( + DCChargeParameterDiscovery, + next_req, + Timeouts.CHARGE_PARAMETER_DISCOVERY_REQ, + Namespace.ISO_V20_DC, + ISOV20PayloadTypes.DC_MAINSTREAM, + ) + elif self.comm_session.selected_energy_service.service == ServiceV20.DC_BPT: + next_req = DCChargeParameterDiscoveryReq( + header=MessageHeader( + session_id=self.comm_session.session_id, + timestamp=time.time(), + ), + bpt_dc_params=charge_params, + ) + + self.create_next_message( + DCChargeParameterDiscovery, + next_req, + Timeouts.CHARGE_PARAMETER_DISCOVERY_REQ, + Namespace.ISO_V20_DC, + ISOV20PayloadTypes.DC_MAINSTREAM, + ) + else: + # TODO Implement support for other energy transfer services + logger.error( + "Energy transfer mode for service " + f"{self.comm_session.selected_energy_service.service} " + "not yet supported in ServiceSelection" + ) + + +class ScheduleExchange(StateEVCC): """ - The ISO 15118-20 state in which the EVCC processes an AuthorizationRes + The ISO 15118-20 state in which the EVCC processes a ScheduleExchangeRes from the SECC. """ def __init__(self, comm_session: EVCCCommunicationSession): - super().__init__(comm_session, Timeouts.AUTHORIZATION_REQ) + super().__init__(comm_session, Timeouts.SCHEDULE_EXCHANGE_REQ) def process_message( self, @@ -277,7 +670,49 @@ def process_message( V2GMessageV20, ], ): - raise NotImplementedError("CertificateInstallation not yet implemented") + raise NotImplementedError("ScheduleExchange not yet implemented") + + +class PowerDelivery(StateEVCC): + """ + The ISO 15118-20 state in which the EVCC processes a PowerDeliveryRes + from the SECC. + """ + + def __init__(self, comm_session: EVCCCommunicationSession): + super().__init__(comm_session, Timeouts.POWER_DELIVERY_REQ) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + raise NotImplementedError("PowerDelivery not yet implemented") + + +class SessionStop(StateEVCC): + """ + The ISO 15118-20 state in which the EVCC processes a SessionStopRes + from the SECC. + """ + + def __init__(self, comm_session: EVCCCommunicationSession): + super().__init__(comm_session, Timeouts.SESSION_STOP_REQ) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + raise NotImplementedError("SessionStop not yet implemented") # ============================================================================ @@ -288,7 +723,7 @@ def process_message( class ACChargeParameterDiscovery(StateEVCC): """ The ISO 15118-20 state in which the EVCC processes an - ACChargeParameterDiscoveryReq from the SECC. + ACChargeParameterDiscoveryRes from the SECC. """ def __init__(self, comm_session: EVCCCommunicationSession): @@ -309,7 +744,7 @@ def process_message( class ACChargeLoop(StateEVCC): """ The ISO 15118-20 state in which the EVCC processes an - ACChargeLoopReq from the SECC. + ACChargeLoopRes from the SECC. """ def __init__(self, comm_session: EVCCCommunicationSession): @@ -335,7 +770,7 @@ def process_message( class DCChargeParameterDiscovery(StateEVCC): """ The ISO 15118-20 state in which the EVCC processes a - DCChargeParameterDiscoveryReq from the SECC. + DCChargeParameterDiscoveryRes from the SECC. """ def __init__(self, comm_session: EVCCCommunicationSession): @@ -350,13 +785,47 @@ def process_message( V2GMessageV20, ], ): - raise NotImplementedError("DCChargeParameterDiscovery not yet implemented") + msg = self.check_msg_v20(message, DCChargeParameterDiscoveryRes) + if not msg: + return + + dc_cpd_res: DCChargeParameterDiscoveryRes = msg + # TODO Act upon the possible negative response codes in dc_cpd_res + + scheduled_params, dynamic_params = None, None + if self.comm_session.control_mode == ControlMode.SCHEDULED: + scheduled_params = self.comm_session.ev_controller.get_scheduled_se_params( + self.comm_session.selected_energy_service + ) + + if self.comm_session.control_mode == ControlMode.DYNAMIC: + dynamic_params = self.comm_session.ev_controller.get_dynamic_se_params( + self.comm_session.selected_energy_service + ) + + schedule_exchange_req = ScheduleExchangeReq( + header=MessageHeader( + session_id=self.comm_session.session_id, + timestamp=time.time(), + ), + max_supporting_points=self.comm_session.config.max_supporting_points, + scheduled_params=scheduled_params, + dynamic_params=dynamic_params, + ) + + self.create_next_message( + ScheduleExchange, + schedule_exchange_req, + Timeouts.SCHEDULE_EXCHANGE_REQ, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.SCHEDULE_RENEGOTIATION, + ) class DCCableCheck(StateEVCC): """ The ISO 15118-20 state in which the EVCC processes a - DCCableCheckReq from the SECC. + DCCableCheckRes from the SECC. """ def __init__(self, comm_session: EVCCCommunicationSession): @@ -377,7 +846,7 @@ def process_message( class DCPreCharge(StateEVCC): """ The ISO 15118-20 state in which the EVCC processes a - DCPreChargeReq from the SECC. + DCPreChargeRes from the SECC. """ def __init__(self, comm_session: EVCCCommunicationSession): @@ -398,7 +867,7 @@ def process_message( class DCChargeLoop(StateEVCC): """ The ISO 15118-20 state in which the EVCC processes a - DCChargeLoopReq from the SECC. + DCChargeLoopRes from the SECC. """ def __init__(self, comm_session: EVCCCommunicationSession): @@ -419,7 +888,7 @@ def process_message( class DCWeldingDetection(StateEVCC): """ The ISO 15118-20 state in which the EVCC processes a - DCWeldingDetectionReq from the SECC. + DCWeldingDetectionRes from the SECC. """ def __init__(self, comm_session: EVCCCommunicationSession): diff --git a/iso15118/evcc/states/sap_states.py b/iso15118/evcc/states/sap_states.py index 154c8d8b..f694f64b 100644 --- a/iso15118/evcc/states/sap_states.py +++ b/iso15118/evcc/states/sap_states.py @@ -106,10 +106,9 @@ def process_message( # This should not happen because the EVCC previously # should have sent a valid SupportedAppProtocolReq logger.error( - "EVCC sent an invalid protocol namespace in " - f"its previous SupportedAppProtocolReq: " - f"{protocol.protocol_ns}. Allowed namespaces are:" - f" {self.comm_session.config.supported_protocols}" + "EVCC sent an invalid protocol namespace in its previous" + f"SupportedAppProtocolReq: {protocol.protocol_ns}. Allowed " + f"namespaces: {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 5e35adf0..3b51b431 100644 --- a/iso15118/secc/comm_session_handler.py +++ b/iso15118/secc/comm_session_handler.py @@ -39,6 +39,7 @@ from iso15118.shared.messages.iso15118_2.datatypes import ( SAScheduleTupleEntry, ServiceDetails, + Service, ) from iso15118.shared.messages.sdp import SDPRequest, Security, create_sdp_response from iso15118.shared.messages.timeouts import Timeouts @@ -82,6 +83,9 @@ def __init__( # The authorization option (called PaymentOption in ISO 15118-2) the # EVCC selected with the PaymentServiceSelectionReq self.selected_auth_option: Optional[AuthEnum] = None + # The generated challenge sent in PaymentDetailsRes. Its copy is expected in + # AuthorizationReq (applies to Plug & Charge identification mode only) + self.gen_challenge: bytes = bytes(0) # In ISO 15118-2, the EVCCID is the MAC address, given as bytes. # In ISO 15118-20, the EVCCID is like a VIN number, given as str. self.evcc_id: Union[bytes, str, None] = None diff --git a/iso15118/secc/controller/interface.py b/iso15118/secc/controller/interface.py index bd4d1967..1b769538 100644 --- a/iso15118/secc/controller/interface.py +++ b/iso15118/secc/controller/interface.py @@ -13,15 +13,29 @@ DCEVSEChargeParameter, DCEVSEStatus, EnergyTransferModeEnum, -) -from iso15118.shared.messages.iso15118_2.datatypes import MeterInfo as MeterInfoV2 -from iso15118.shared.messages.iso15118_2.datatypes import ( PVEVSEPresentCurrent, PVEVSEPresentVoltage, SAScheduleTupleEntry, + MeterInfo as MeterInfoV2 +) +from iso15118.shared.messages.iso15118_20.ac import ( + ACChargeParameterDiscoveryResParams, + BPTACChargeParameterDiscoveryResParams, +) +from iso15118.shared.messages.iso15118_20.common_messages import ( + ProviderID, + ServiceList, + ServiceParameterList, + SelectedEnergyService, + ScheduledScheduleExchangeResParams, + DynamicScheduleExchangeResParams, + ScheduleExchangeReq, ) -from iso15118.shared.messages.iso15118_20.common_messages import ProviderID from iso15118.shared.messages.iso15118_20.common_types import MeterInfo as MeterInfoV20 +from iso15118.shared.messages.iso15118_20.dc import ( + DCChargeParameterDiscoveryResParams, + BPTDCChargeParameterDiscoveryResParams, +) class EVSEControllerInterface(ABC): @@ -46,14 +60,103 @@ def get_evse_id(self) -> str: @abstractmethod 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 + The available energy transfer modes, which depends on the socket the EV is + connected to. Relevant for: - ISO 15118-2 """ raise NotImplementedError + @abstractmethod + def get_charge_params_v20( + self, selected_service: SelectedEnergyService + ) -> Union[ + ACChargeParameterDiscoveryResParams, + BPTACChargeParameterDiscoveryResParams, + DCChargeParameterDiscoveryResParams, + BPTDCChargeParameterDiscoveryResParams, + ]: + """ + Gets the charge parameters needed for a ChargeParameterDiscoveryReq. + + Args: + selected_service: The energy transfer service, which the EVCC selected, and + for which we need the SECC's charge parameters + + Returns: + Charge parameters for either unidirectional or bi-directional power + transfer needed for a ChargeParameterDiscoveryRes. + + Relevant for: + - ISO 15118-20 + """ + raise NotImplementedError + + @abstractmethod + def get_scheduled_se_params( + self, + selected_energy_service: SelectedEnergyService, + schedule_exchange_req: ScheduleExchangeReq, + ) -> Optional[ScheduledScheduleExchangeResParams]: + """ + Gets the parameters for a ScheduleExchangeResponse, which correspond to the + Scheduled control mode. If the parameters are not yet ready when requested, + return None. + + Args: + selected_energy_service: The energy services, which the EVCC selected. + The selected parameter set, that is associated + with that energy service, influences the + parameters for the ScheduleExchangeRes + schedule_exchange_req: The ScheduleExchangeReq, whose parameters influence + the parameters for the ScheduleExchangeRes + + Returns: + Parameters for the ScheduleExchangeRes in Scheduled control mode, if + readily available. If you're still waiting for all parameters, return None. + + Relevant for: + - ISO 15118-20 + """ + + @abstractmethod + def get_dynamic_se_params( + self, + selected_energy_service: SelectedEnergyService, + schedule_exchange_req: ScheduleExchangeReq, + ) -> Optional[DynamicScheduleExchangeResParams]: + """ + Gets the parameters for a ScheduleExchangeResponse, which correspond to the + Dynamic control mode. If the parameters are not yet ready when requested, + return None. + + Args: + selected_energy_service: The energy services, which the EVCC selected. + The selected parameter set, that is associated + with that energy service, influences the + parameters for the ScheduleExchangeRes + schedule_exchange_req: The ScheduleExchangeReq, whose parameters influence + the parameters for the ScheduleExchangeRes + + Returns: + Parameters for the ScheduleExchangeRes in Dynamic control mode, if + readily available. If you're still waiting for all parameters, return None. + + Relevant for: + - ISO 15118-20 + """ + + @abstractmethod + def get_energy_service_list(self) -> ServiceList: + """ + The available energy transfer services + + Relevant for: + - ISO 15118-20 + """ + raise NotImplementedError + @abstractmethod def is_authorised(self) -> bool: """ @@ -71,7 +174,9 @@ def is_authorised(self) -> bool: @abstractmethod def get_sa_schedule_list( - self, max_schedule_entries: Optional[int], departure_time: int = 0 + self, + max_schedule_entries: Optional[int], + departure_time: int = 0 ) -> Optional[List[SAScheduleTupleEntry]]: """ Requests the charging schedule from a secondary actor (SA) like a @@ -132,6 +237,27 @@ def get_supported_providers(self) -> Optional[List[ProviderID]]: """ raise NotImplementedError + @abstractmethod + def service_renegotiation_supported(self) -> bool: + """ + Whether or not service renegotiation is supported + + Relevant for: + - ISO 15118-20 + """ + raise NotImplementedError + + @abstractmethod + def get_service_parameter_list(self, service_id: int) -> ServiceParameterList: + """ + Provides a list of parameters for a specific service ID for which the EVCC + requests additional information + + Relevant for: + - ISO 15118-20 + """ + raise NotImplementedError + # ============================================================================ # | AC-SPECIFIC FUNCTIONS | # ============================================================================ diff --git a/iso15118/secc/controller/simulator.py b/iso15118/secc/controller/simulator.py index b01c6510..48f249dc 100644 --- a/iso15118/secc/controller/simulator.py +++ b/iso15118/secc/controller/simulator.py @@ -3,6 +3,7 @@ (Electric Vehicle Supply Equipment). """ import logging +import random import time from typing import List, Optional, Union @@ -18,9 +19,7 @@ EnergyTransferModeEnum, EVSENotification, IsolationLevel, -) -from iso15118.shared.messages.iso15118_2.datatypes import MeterInfo as MeterInfoV2 -from iso15118.shared.messages.iso15118_2.datatypes import ( + MeterInfo as MeterInfoV2, PMaxScheduleEntry, PMaxScheduleEntryDetails, PVEVSEMaxCurrent, @@ -36,6 +35,59 @@ ) from iso15118.shared.messages.iso15118_20.common_messages import ProviderID from iso15118.shared.messages.iso15118_20.common_types import MeterInfo as MeterInfoV20 +from iso15118.shared.messages.enums import ( + Namespace, + Protocol, + ServiceV20, + ParameterName, + ControlMode, + DCConnector, + MobilityNeedsMode, + Pricing, + PriceAlgorithm, +) +from iso15118.shared.messages.iso15118_20.ac import ( + ACChargeParameterDiscoveryResParams, + BPTACChargeParameterDiscoveryResParams, +) +from iso15118.shared.messages.iso15118_20.common_messages import ( + ProviderID, + Service, + ServiceList, + ServiceParameterList, + ParameterSet, + Parameter, + SelectedEnergyService, + ScheduledScheduleExchangeResParams, + DynamicScheduleExchangeResParams, + ScheduleTuple, + ChargingSchedule, + PowerSchedule, + AbsolutePriceSchedule, + PowerScheduleEntryList, + PowerScheduleEntry, + TaxRuleList, + PriceRuleStackList, + OverstayRuleList, + AdditionalServiceList, + TaxRule, + PriceRuleStack, + PriceRule, + OverstayRule, + AdditionalService, + PriceLevelSchedule, + PriceLevelScheduleEntryList, + PriceLevelScheduleEntry, + ScheduleExchangeReq, +) +from iso15118.shared.messages.iso15118_20.common_types import ( + MeterInfo as MeterInfoV20, + RationalNumber, +) +from iso15118.shared.messages.iso15118_20.dc import ( + DCChargeParameterDiscoveryResParams, + BPTDCChargeParameterDiscoveryResParams, +) logger = logging.getLogger(__name__) @@ -59,6 +111,220 @@ def get_supported_energy_transfer_modes(self) -> List[EnergyTransferModeEnum]: ac_three_phase = EnergyTransferModeEnum.AC_THREE_PHASE_CORE return [ac_single_phase, ac_three_phase] + def get_charge_params_v20( + self, selected_service: SelectedEnergyService + ) -> Union[ + ACChargeParameterDiscoveryResParams, + BPTACChargeParameterDiscoveryResParams, + DCChargeParameterDiscoveryResParams, + BPTDCChargeParameterDiscoveryResParams, + ]: + """Overrides EVSEControllerInterface.get_charge_params_v20().""" + if selected_service.service == ServiceV20.AC: + return ACChargeParameterDiscoveryResParams( + ev_max_charge_power=RationalNumber(exponent=3, value=11), + ev_min_charge_power=RationalNumber(exponent=0, value=100), + ) + elif selected_service.service == ServiceV20.AC_BPT: + return BPTACChargeParameterDiscoveryResParams( + ev_max_charge_power=RationalNumber(exponent=3, value=11), + ev_min_charge_power=RationalNumber(exponent=0, value=100), + ev_max_discharge_power=RationalNumber(exponent=3, value=11), + ev_min_discharge_power=RationalNumber(exponent=0, value=100), + ) + elif selected_service.service == ServiceV20.DC: + return DCChargeParameterDiscoveryResParams( + evse_max_charge_power=RationalNumber(exponent=3, value=300), + evse_min_charge_power=RationalNumber(exponent=0, value=100), + evse_max_charge_current=RationalNumber(exponent=0, value=300), + evse_min_charge_current=RationalNumber(exponent=0, value=10), + evse_max_voltage=RationalNumber(exponent=0, value=1000), + evse_min_voltage=RationalNumber(exponent=0, value=10), + ) + elif selected_service.service == ServiceV20.DC_BPT: + return BPTDCChargeParameterDiscoveryResParams( + evse_max_charge_power=RationalNumber(exponent=3, value=300), + evse_min_charge_power=RationalNumber(exponent=0, value=100), + evse_max_charge_current=RationalNumber(exponent=0, value=300), + evse_min_charge_current=RationalNumber(exponent=0, value=10), + evse_max_voltage=RationalNumber(exponent=0, value=1000), + evse_min_oltage=RationalNumber(exponent=0, value=10), + evse_max_discharge_power=RationalNumber(exponent=3, value=11), + evse_min_discharge_power=RationalNumber(exponent=3, value=1), + evse_max_discharge_current=RationalNumber(exponent=0, value=11), + evse_min_discharge_current=RationalNumber(exponent=0, value=0), + ) + else: + # TODO Implement the remaining energy transer services + logger.error("Energy transfer service not supported") + + def get_scheduled_se_params( + self, + selected_energy_service: SelectedEnergyService, + schedule_exchange_req: ScheduleExchangeReq, + ) -> Optional[ScheduledScheduleExchangeResParams]: + is_ready = bool(random.getrandbits(1)) + if not is_ready: + logger.debug("Scheduled parameters for ScheduleExchangeRes not yet ready") + return None + + """Overrides EVSEControllerInterface.get_scheduled_se_params().""" + charging_power_schedule_entry = PowerScheduleEntry( + duration=3600, + power=RationalNumber(exponent=3, value=10) + # Check if AC ThreePhase applies (Connector parameter within parameter set + # of SelectedEnergyService) if you want to add power_l2 and power_l3 values + ) + + charging_power_schedule = PowerSchedule( + time_anchor=0, + available_energy=RationalNumber(exponent=3, value=300), + power_tolerance=RationalNumber(exponent=0, value=2000), + power_schedule_entries=PowerScheduleEntryList( + power_schedule_entries=[charging_power_schedule_entry] + ), + ) + + tax_rule = TaxRule( + tax_rule_id=1, + tax_rule_name="What a great tax rule", + tax_rate=RationalNumber(exponent=0, value=10), + tax_included_in_price=False, + applies_to_energy_fee=True, + applies_to_parking_fee=True, + applies_to_overstay_fee=True, + applies_to_min_max_cost=True, + ) + + tax_rules = TaxRuleList(tax_rule=[tax_rule]) + + price_rule = PriceRule( + energy_fee=RationalNumber(exponent=0, value=20), + parking_fee=RationalNumber(exponent=0, value=0), + parking_fee_period=0, + carbon_dioxide_emission=0, + renewable_generation_percentage=0, + power_range_start=RationalNumber(exponent=0, value=0), + ) + + price_rule_stack = PriceRuleStack(duration=3600, price_rules=[price_rule]) + + price_rule_stacks = PriceRuleStackList(price_rule_stacks=[price_rule_stack]) + + overstay_rule = OverstayRule( + description="What a great description", + start_time=0, + fee=RationalNumber(exponent=0, value=50), + fee_period=3600, + ) + + overstay_rules = OverstayRuleList( + time_shreshold=3600, + power_threshold=RationalNumber(exponent=3, value=30), + rules=[overstay_rule], + ) + + additional_service = AdditionalService( + service_name="What a great service name", + service_fee=RationalNumber(exponent=0, value=0), + ) + + additional_services = AdditionalServiceList( + additional_services=[additional_service] + ) + + charging_absolute_price_schedule = AbsolutePriceSchedule( + currency="EUR", + language="ENG", + price_algorithm=PriceAlgorithm.POWER, + min_cost=RationalNumber(exponent=0, value=1), + max_cost=RationalNumber(exponent=0, value=10), + tax_rules=tax_rules, + price_rule_stacks=price_rule_stacks, + overstay_rules=overstay_rules, + additional_services=additional_services, + ) + + discharging_power_schedule = PowerSchedule( + duration=3600, + power=RationalNumber(exponent=3, value=-5) + # Check if AC ThreePhase applies (Connector parameter within parameter set + # of SelectedEnergyService) if you want to add power_l2 and power_l3 values + ) + + discharging_absolute_price_schedule = charging_absolute_price_schedule + + charging_schedule = ChargingSchedule( + power_schedule=charging_power_schedule, + absolute_price_schedule=charging_absolute_price_schedule, + ) + + discharging_schedule = ChargingSchedule( + power_schedule=discharging_power_schedule, + absolute_price_schedule=discharging_absolute_price_schedule, + ) + + schedule_tuple = ScheduleTuple( + schedule_tuple_id=1, + charging_schedule=charging_schedule, + discharging_schedule=discharging_schedule, + ) + + scheduled_params = ScheduledScheduleExchangeResParams( + schedule_tuples=[schedule_tuple] + ) + + return scheduled_params + + def get_dynamic_se_params( + self, + selected_energy_service: SelectedEnergyService, + schedule_exchange_req: ScheduleExchangeReq, + ) -> Optional[DynamicScheduleExchangeResParams]: + """Overrides EVSEControllerInterface.get_dynamic_se_params().""" + is_ready = bool(random.getrandbits(1)) + if not is_ready: + logger.debug("Dynamic parameters for ScheduleExchangeRes not yet ready") + return None + + price_level_schedule_entry = PriceLevelScheduleEntry( + duration=3600, price_level=1 + ) + + schedule_entries = PriceLevelScheduleEntryList( + entries=[price_level_schedule_entry] + ) + + price_level_schedule = PriceLevelSchedule( + id="id1", + time_anchor=0, + schedule_id=1, + schedule_description="What a great description", + num_price_levels=1, + schedule_entries=schedule_entries, + ) + + dynamic_params = DynamicScheduleExchangeResParams( + departure_time=7200, + min_soc=30, + target_soc=80, + price_level_schedule=price_level_schedule, + ) + + return dynamic_params + + def get_energy_service_list(self) -> ServiceList: + """Overrides EVSEControllerInterface.get_energy_service_list().""" + # AC = 1, DC = 2, AC_BPT = 5, DC_BPT = 6 + service_ids = [2] + service_list: ServiceList = ServiceList(services=[]) + for service_id in service_ids: + service_list.services.append( + Service(service_id=service_id, free_service=False) + ) + + return service_list + def is_authorised(self) -> bool: """Overrides EVSEControllerInterface.is_authorised().""" return True @@ -98,7 +364,7 @@ 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_entry, + p_max_schedule=p_max_schedule_entries, sales_tariff=sales_tariff, ) @@ -136,6 +402,10 @@ def get_supported_providers(self) -> Optional[List[ProviderID]]: """Overrides EVSEControllerInterface.get_supported_providers().""" return None + def service_renegotiation_supported(self) -> bool: + """Overrides EVSEControllerInterface.service_renegotiation_supported().""" + return False + # ============================================================================ # | AC-SPECIFIC FUNCTIONS | # ============================================================================ @@ -159,6 +429,51 @@ def get_ac_evse_charge_parameter(self) -> ACEVSEChargeParameter: evse_nominal_voltage=evse_nominal_voltage, evse_max_current=evse_max_current, ) + evse_nominal_voltage = PVEVSENominalVoltage( + multiplier=0, value=400, unit=UnitSymbol.VOLTAGE + ) + evse_max_current = PVEVSEMaxCurrent( + multiplier=0, value=32, unit=UnitSymbol.AMPERE + ) + return ACEVSEChargeParameter( + ac_evse_status=self.get_ac_evse_status(), + evse_nominal_voltage=evse_nominal_voltage, + evse_max_current=evse_max_current, + ) + + def get_service_parameter_list(self, service_id: int) -> ServiceParameterList: + """Overrides EVSEControllerInterface.get_service_parameter_list().""" + if service_id == ServiceV20.DC.service_id: + service_parameter_list = ServiceParameterList( + parameter_sets=[ + ParameterSet( + id=1, + parameters=[ + Parameter( + name=ParameterName.CONNECTOR, + int_value=DCConnector.EXTENDED, + ), + Parameter( + name=ParameterName.CONTROL_MODE, + int_value=ControlMode.DYNAMIC, + ), + Parameter( + name=ParameterName.MOBILITY_NEEDS_MODE, + int_value=MobilityNeedsMode.EVCC_ONLY, + ), + Parameter( + name=ParameterName.PRICING, int_value=Pricing.NONE + ), + ], + ) + ] + ) + + return service_parameter_list + else: + logger.error( + f"Unknown service ID {service_id}, can't provide ServiceParameterList" + ) # ============================================================================ # | DC-SPECIFIC FUNCTIONS | diff --git a/iso15118/secc/failed_responses.py b/iso15118/secc/failed_responses.py index 0d6d72b0..df974912 100644 --- a/iso15118/secc/failed_responses.py +++ b/iso15118/secc/failed_responses.py @@ -1,4 +1,4 @@ -from iso15118.shared.messages.enums import AuthEnum, Namespace +from iso15118.shared.messages.enums import AuthEnum, Namespace, ISOV20PayloadTypes from iso15118.shared.messages.iso15118_2.body import EMAID from iso15118.shared.messages.iso15118_2.body import ( AuthorizationReq as AuthorizationReqV2, @@ -62,7 +62,7 @@ from iso15118.shared.messages.iso15118_2.body import ( WeldingDetectionRes as WeldingDetectionResV2, ) -from iso15118.shared.messages.iso15118_2.datatypes import ACEVSEStatus, AuthOptionList +from iso15118.shared.messages.iso15118_2.datatypes import ACEVSEStatus, AuthOptions from iso15118.shared.messages.iso15118_2.datatypes import ( CertificateChain as CertificateChainV2, ) @@ -72,8 +72,8 @@ DCEVSEStatusCode, DHPublicKey, EncryptedPrivateKey, + EnergyTransferMode, EnergyTransferModeEnum, - EnergyTransferModeList, EVSENotification, EVSEProcessing, IsolationLevel, @@ -88,10 +88,12 @@ ACChargeParameterDiscoveryReq, ACChargeParameterDiscoveryRes, ACChargeParameterDiscoveryResParams, - ScheduledACChargeLoopResParamsParams, + ScheduledACChargeLoopResParams, ) from iso15118.shared.messages.iso15118_20.common_messages import ( AuthorizationReq as AuthorizationReqV20, + SubCertificates, + ServiceList, ) from iso15118.shared.messages.iso15118_20.common_messages import ( AuthorizationRes as AuthorizationResV20, @@ -125,7 +127,6 @@ from iso15118.shared.messages.iso15118_20.common_messages import ( PriceLevelSchedule, PriceLevelScheduleEntryList, - ScheduledScheduleExchangeResParams, ScheduleExchangeReq, ScheduleExchangeRes, Service, @@ -136,7 +137,6 @@ from iso15118.shared.messages.iso15118_20.common_messages import ( ServiceDetailRes as ServiceDetailResV20, ) -from iso15118.shared.messages.iso15118_20.common_messages import ServiceDetails from iso15118.shared.messages.iso15118_20.common_messages import ( ServiceDiscoveryReq as ServiceDiscoveryReqV20, ) @@ -161,9 +161,6 @@ 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, ) @@ -173,6 +170,11 @@ ) # TODO: Implement DIN SPEC 70121 case (failed_response_din = {}) +from iso15118.shared.messages.iso15118_20.dc import ( + DCChargeParameterDiscoveryReq, + DCChargeParameterDiscoveryRes, + DCChargeParameterDiscoveryResParams, +) def init_failed_responses_iso_v2() -> dict: @@ -191,14 +193,14 @@ def init_failed_responses_iso_v2() -> dict: ), ServiceDiscoveryReqV2: ServiceDiscoveryResV2( response_code=ResponseCodeV2.FAILED, - auth_option_list=AuthOptionList(auth_options=[AuthEnum.EIM_V2]), + auth_option_list=[AuthOptions(value=AuthEnum.EIM_V2)], charge_service=ChargeService( service_id=ServiceID.CHARGING, service_category=ServiceCategory.CHARGING, free_service=False, - supported_energy_transfer_mode=EnergyTransferModeList( - energy_modes=[EnergyTransferModeEnum.DC_CORE] - ), + supported_energy_transfer_mode=[ + EnergyTransferMode(value=EnergyTransferModeEnum.DC_CORE) + ], ), ), ServiceDetailReqV2: ServiceDetailResV2( @@ -328,6 +330,7 @@ def init_failed_responses_iso_v20() -> dict: header=header, response_code=ResponseCodeV20.FAILED, evse_id="" ), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), AuthorizationReqV20: ( AuthorizationResV20( @@ -336,6 +339,7 @@ def init_failed_responses_iso_v20() -> dict: evse_processing=Processing.FINISHED, ), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), AuthorizationSetupReq: ( AuthorizationSetupRes( @@ -346,6 +350,7 @@ def init_failed_responses_iso_v20() -> dict: eim_as_res=EIMAuthSetupResParams(), ), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), CertificateInstallationReqV20: ( CertificateInstallationResV20( @@ -356,7 +361,7 @@ def init_failed_responses_iso_v20() -> dict: signed_installation_data=SignedInstallationData( contract_cert_chain=CertificateChainV20( certificate=bytes(0), - sub_certificates=SubCertificatesV20(certificates=[bytes(0)]), + sub_certificates=SubCertificates(certificate=[bytes(0)]), ), ecdh_curve=ECDHCurve.x448, dh_public_key=bytes(0), @@ -366,32 +371,34 @@ def init_failed_responses_iso_v20() -> dict: remaining_contract_cert_chains=0, ), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), ServiceDiscoveryReqV20: ( ServiceDiscoveryResV20( header=header, response_code=ResponseCodeV20.FAILED, service_renegotiation_supported=False, - energy_transfer_service_list=[ - Service( - service_details=ServiceDetails(service_id=0, free_service=False) - ) - ], + energy_service_list=ServiceList( + services=[Service(service_id=0, free_service=False)], + ), ), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), ServiceDetailReqV20: ( ServiceDetailResV20( header=header, response_code=ResponseCodeV20.FAILED, service_id=0, - service_parameter_list=ServiceParameterList(parameter_set=[]), + service_parameter_list=ServiceParameterList(parameter_sets=[]), ), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), ServiceSelectionReq: ( ServiceSelectionRes(header=header, response_code=ResponseCodeV20.FAILED), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), ACChargeParameterDiscoveryReq: ( ACChargeParameterDiscoveryRes( @@ -404,8 +411,24 @@ def init_failed_responses_iso_v20() -> dict: ), ), Namespace.ISO_V20_AC, + ISOV20PayloadTypes.AC_MAINSTREAM, + ), + DCChargeParameterDiscoveryReq: ( + DCChargeParameterDiscoveryRes( + header=header, + response_code=ResponseCodeV20.FAILED, + dc_params=DCChargeParameterDiscoveryResParams( + evse_max_charge_power=RationalNumber(exponent=0, value=0), + evse_min_charge_power=RationalNumber(exponent=0, value=0), + evse_max_charge_current=RationalNumber(exponent=0, value=0), + evse_min_charge_current=RationalNumber(exponent=0, value=0), + evse_max_voltage=RationalNumber(exponent=0, value=0), + evse_min_voltage=RationalNumber(exponent=0, value=0), + ), + ), + Namespace.ISO_V20_DC, + ISOV20PayloadTypes.DC_MAINSTREAM, ), - # DCChargeParameterDiscoveryReq: # TODO Need to add DC messages for ISO 15118-20 # None, # WPTChargeParameterDiscoveryReq: @@ -437,29 +460,31 @@ def init_failed_responses_iso_v20() -> dict: header=header, response_code=ResponseCodeV20.FAILED, evse_processing=Processing.ONGOING, - scheduled_se_res=ScheduledScheduleExchangeResParams(schedule_tuple=[]), - dynamic_se_res=DynamicScheduleExchangeResParams( + dynamic_params=DynamicScheduleExchangeResParams( price_level_schedule=PriceLevelSchedule( time_anchor=0, - price_schedule_id=1, + schedule_id=1, num_price_levels=0, - schedule_entries=PriceLevelScheduleEntryList(entry=[]), + schedule_entries=PriceLevelScheduleEntryList(entries=[]), ) ), ), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), PowerDeliveryReqV20: ( PowerDeliveryResV20(header=header, response_code=ResponseCodeV20.FAILED), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), ACChargeLoopReq: ( ACChargeLoopRes( header=header, response_code=ResponseCodeV20.FAILED, - scheduled_ac_charge_loop_res=ScheduledACChargeLoopResParamsParams(), + scheduled_ac_charge_loop_res=ScheduledACChargeLoopResParams(), ), Namespace.ISO_V20_AC, + ISOV20PayloadTypes.AC_MAINSTREAM, ), # DCChargeLoopReq: # TODO Need to add DC messages for ISO 15118-20 @@ -472,6 +497,7 @@ def init_failed_responses_iso_v20() -> dict: header=header, response_code=ResponseCodeV20.FAILED ), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), # DCWeldingDetectionReq: # TODO Need to add DC messages for ISO 15118-20 @@ -485,6 +511,7 @@ def init_failed_responses_iso_v20() -> dict: SessionStopReqV20: ( SessionStopResV20(header=header, response_code=ResponseCodeV20.FAILED), Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ), } diff --git a/iso15118/secc/states/iso15118_20_states.py b/iso15118/secc/states/iso15118_20_states.py index b6864427..54c703b7 100644 --- a/iso15118/secc/states/iso15118_20_states.py +++ b/iso15118/secc/states/iso15118_20_states.py @@ -6,7 +6,7 @@ import logging import time -from typing import List, Union +from typing import List, Union, Optional, Tuple from iso15118.secc.comm_session_handler import SECCCommunicationSession from iso15118.secc.states.secc_state import StateSECC @@ -15,7 +15,14 @@ SupportedAppProtocolReq, SupportedAppProtocolRes, ) -from iso15118.shared.messages.enums import AuthEnum, Namespace +from iso15118.shared.messages.enums import ( + AuthEnum, + Namespace, + ISOV20PayloadTypes, + ServiceV20, + ParameterName, + ControlMode, +) from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 from iso15118.shared.messages.iso15118_20.common_messages import ( AuthorizationReq, @@ -29,6 +36,21 @@ SessionSetupReq, SessionSetupRes, SessionStopReq, + ServiceDetailReq, + ServiceDiscoveryRes, + ServiceIDList, + ServiceSelectionReq, + ServiceList, + ServiceDetailRes, + ServiceSelectionRes, + OfferedService, + SelectedEnergyService, + SelectedService, + SelectedVAS, + SelectedServiceList, + ScheduleExchangeReq, + ScheduleExchangeRes, + PowerDeliveryReq, ) from iso15118.shared.messages.iso15118_20.common_types import ( MessageHeader, @@ -38,6 +60,13 @@ from iso15118.shared.messages.iso15118_20.common_types import ( V2GMessage as V2GMessageV20, ) +from iso15118.shared.messages.iso15118_20.dc import ( + DCChargeParameterDiscoveryReq, + DCChargeParameterDiscoveryRes, + DCChargeParameterDiscoveryReqParams, + BPTDCChargeParameterDiscoveryReqParams, + DCCableCheckReq, +) from iso15118.shared.messages.iso15118_20.timeouts import Timeouts from iso15118.shared.security import get_random_bytes, verify_signature @@ -107,6 +136,7 @@ def process_message( session_setup_res, Timeouts.V2G_SECC_SEQUENCE_TIMEOUT, Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ) @@ -178,9 +208,11 @@ def process_message( eim_as_res = EIMAuthSetupResParams() if AuthEnum.PNC in supported_auth_options: auth_options.append(AuthEnum.PNC) + self.comm_session.gen_challenge = get_random_bytes(16) pnc_as_res = PnCAuthSetupResParams( - gen_challenge=get_random_bytes(16), - supported_providers=self.comm_session.evse_controller.get_supported_providers(), # noqa: E501 + gen_challenge=self.comm_session.gen_challenge, + supported_providers= + self.comm_session.evse_controller.get_supported_providers(), ) # TODO [V2G20-2096], [V2G20-2570] @@ -202,6 +234,7 @@ def process_message( auth_setup_res, Timeouts.V2G_SECC_SEQUENCE_TIMEOUT, Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, ) self.expecting_auth_setup_req = False @@ -289,57 +322,523 @@ def process_message( return auth_req: AuthorizationReq = msg + response_code: ResponseCode = ResponseCode.OK - # Verify signature if EVCC sent PnC authorization data - if auth_req.pnc_params and not verify_signature( - auth_req.header.signature, - [ - ( - auth_req.pnc_params.id, - to_exi(auth_req.pnc_params, Namespace.ISO_V20_COMMON_MSG), + if auth_req.pnc_params: + if not verify_signature( + auth_req.header.signature, + [ + ( + auth_req.pnc_params.id, + to_exi(auth_req.pnc_params, Namespace.ISO_V20_COMMON_MSG), + ) + ], + auth_req.pnc_params.contract_cert_chain.certificate, + ): + # TODO: There are more fine-grained WARNING response codes available + self.stop_state_machine( + "Unable to verify signature for AuthorizationReq", + message, + ResponseCode.FAILED_SIGNATURE_ERROR, ) - ], - self.comm_session.contract_cert_chain.certificate, - ): - # TODO: There are more fine-grained WARNING response codes available + return + + if auth_req.pnc_params.gen_challenge != self.comm_session.gen_challenge: + response_code = ResponseCode.WARN_CHALLENGE_INVALID + + if self.comm_session.evse_controller.is_authorised(): + auth_status = Processing.FINISHED + else: + auth_status = Processing.ONGOING + # TODO Need to distinguish between ONGOING and WAITING_FOR_CUSTOMER + + auth_res = AuthorizationRes( + header=MessageHeader( + session_id=self.comm_session.session_id, timestamp=time.time() + ), + response_code=response_code, + evse_processing=auth_status, + ) + + self.create_next_message( + None, + auth_res, + Timeouts.V2G_SECC_SEQUENCE_TIMEOUT, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, + ) + + if auth_status == Processing.FINISHED: + self.expecting_authorization_req = False + else: + self.expecting_authorization_req = True + + +class ServiceDiscovery(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes a + ServiceDiscoveryReq from the EVCC. + + The EVCC may send one of the following requests in this state: + 1. ServiceDiscoveryReq + 2. ServiceDetailReq + 3. SessionStopReq + + Upon first initialisation of this state, we expect a ServiceDiscoveryReq + but after that, the next possible request could be a ServiceDetailReq or a + SessionStopReq. This means that we need to remain in this state until we receive + the next message in the sequence. + + As a result, the create_next_message() method is called with next_state = None. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + self.expecting_service_discovery_req = True + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + msg = self.check_msg_v20( + message, + [ServiceDiscoveryReq, ServiceDetailReq, SessionStopReq], + self.expecting_service_discovery_req, + ) + if not msg: + return + + if isinstance(msg, ServiceDetailReq): + ServiceDetail(self.comm_session).process_message(message) + return + + if isinstance(msg, SessionStopReq): + SessionStop(self.comm_session).process_message(message) + return + + service_discovery_req: ServiceDiscoveryReq = msg + + offered_energy_services = ( + self.comm_session.evse_controller.get_energy_service_list() + ) + for energy_service in offered_energy_services.services: + self.comm_session.offered_services_v20.append( + OfferedService( + service=ServiceV20.get_by_id(energy_service.service_id), + is_energy_service=True, + is_free=energy_service.free_service, + # Parameter sets are available with ServiceDetailRes + parameter_sets=[], + ) + ) + + offered_vas = self.get_vas_list(service_discovery_req.supported_service_ids) + if offered_vas: + for vas in offered_vas.services: + self.comm_session.offered_services_v20.append( + OfferedService( + service=ServiceV20.get_by_id(vas.service_id), + is_energy_service=False, + is_free=vas.free_service, + # Parameter sets are available with ServiceDetailRes + parameter_sets=[], + ) + ) + + service_discovery_res = ServiceDiscoveryRes( + header=MessageHeader( + session_id=self.comm_session.session_id, timestamp=time.time() + ), + response_code=ResponseCode.OK, + service_renegotiation_supported= + self.comm_session.evse_controller.service_renegotiation_supported(), + energy_service_list=offered_energy_services, + vas_list=offered_vas, + ) + + self.create_next_message( + None, + service_discovery_res, + Timeouts.V2G_SECC_SEQUENCE_TIMEOUT, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, + ) + + self.expecting_service_discovery_req = False + + def get_vas_list( + self, supported_service_ids: ServiceIDList = None + ) -> Optional[ServiceList]: + """ + Provides a list of value-added services (VAS) offered by the SECC. If the EVCC + provided a SupportedServiceIDs parameter with ServiceDiscoveryReq, then the + offered VAS list must not contain more services than the ones whose IDs are in + this list. + + Args: + supported_service_ids: A list that contains all ServiceIDs that the EV + supports. + + Returns: + A list of offered value-added services, or None, if none are offered. + """ + return None + + +class ServiceDetail(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes a + ServiceDetailReq from the EVCC. + + The EVCC may send one of the following requests in this state: + 1. ServiceDetailReq + 2. ServiceSelectionReq + 3. SessionStopReq + + Upon first initialisation of this state, we expect a ServiceDetailReq + but after that, the next possible request could be a ServiceSelectionReq or a + SessionStopReq. This means that we need to remain in this state until we receive + the next message in the sequence. + + As a result, the create_next_message() method is called with next_state = None. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + self.expecting_service_detail_req = True + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + msg = self.check_msg_v20( + message, + [ServiceDetailReq, ServiceSelectionReq, SessionStopReq], + # TODO Need to rethink this as we may also always expect a SessionStopReq, + # but not always a ServiceSelectionReq. The expect_first parameter + # doesn't work here as good as it does for ISO 15118-2 + self.expecting_service_detail_req, + ) + if not msg: + return + + if isinstance(msg, ServiceSelectionReq): + ServiceSelection(self.comm_session).process_message(message) + return + + if isinstance(msg, SessionStopReq): + SessionStop(self.comm_session).process_message(message) + return + + service_detail_req: ServiceDetailReq = msg + + service_parameter_list = ( + self.comm_session.evse_controller.get_service_parameter_list( + service_detail_req.service_id + ) + ) + for offered_service in self.comm_session.offered_services_v20: + if offered_service.service.id == service_detail_req.service_id: + offered_service.parameter_sets = service_parameter_list.parameter_sets + + service_detail_res = ServiceDetailRes( + header=MessageHeader( + session_id=self.comm_session.session_id, timestamp=time.time() + ), + response_code=ResponseCode.OK, + service_id=service_detail_req.service_id, + service_parameter_list=service_parameter_list, + ) + + self.create_next_message( + None, + service_detail_res, + Timeouts.V2G_SECC_SEQUENCE_TIMEOUT, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, + ) + + self.expecting_service_detail_req = False + + +class ServiceSelection(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes a + ServiceSelectionReq from the EVCC. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + msg = self.check_msg_v20(message, [ServiceSelectionReq, SessionStopReq], False) + if not msg: + return + + if isinstance(msg, SessionStopReq): + SessionStop(self.comm_session).process_message(message) + return + + service_selection_req: ServiceSelectionReq = msg + + valid, reason, res_code = self.check_selected_services(service_selection_req) + if not valid: + self.stop_state_machine(reason, message, res_code) + return + + energy_service_id = service_selection_req.selected_energy_service.service_id + + if energy_service_id in (ServiceV20.AC.id, ServiceV20.AC_BPT.id): + next_state = ACChargeParameterDiscovery + elif energy_service_id in (ServiceV20.DC.id, ServiceV20.DC_BPT.id): + next_state = DCChargeParameterDiscovery + else: + # TODO Implement WPT and ACDP classes to create corresponding elif-branches + # TODO Check if the SECC offered the selected combination of service ID and + # parameter set ID self.stop_state_machine( - "Unable to verify signature for AuthorizationReq", + f"Selected energy transfer service ID '{energy_service_id}' invalid", message, - ResponseCode.FAILED_SIGNATURE_ERROR, + ResponseCode.FAILED_SERVICE_SELECTION_INVALID, ) return - else: - if self.comm_session.evse_controller.is_authorised(): - auth_status = Processing.FINISHED - else: - auth_status = Processing.ONGOING - # TODO Need to distinguish between ONGOING and WAITING_FOR_CUSTOMER - - auth_res = AuthorizationRes( - header=MessageHeader( - session_id=self.comm_session.session_id, timestamp=time.time() - ), - response_code=ResponseCode.OK, - evse_processing=auth_status, + + service_selection_res = ServiceSelectionRes( + header=MessageHeader( + session_id=self.comm_session.session_id, timestamp=time.time() + ), + response_code=ResponseCode.OK, + ) + + self.create_next_message( + next_state, + service_selection_res, + Timeouts.V2G_SECC_SEQUENCE_TIMEOUT, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.MAINSTREAM, + ) + + def check_selected_services( + self, service_req: ServiceSelectionReq + ) -> Tuple[bool, str, Optional[ResponseCode]]: + """ + Checks whether the energy transfer service and value-added services, which the + EVCC selected, were offered by the SECC in the previous ServiceDiscoveryRes. + + Args: + service_req: The EVCC's ServiceSelectionReq message + + Returns: + A tuple containing the following information: + 1. True, if check passed, False otherwise + 2. If False, the reason for not passing (empty if passed) + 3. The corresponding negative response code + """ + req_energy_service: SelectedService = service_req.selected_energy_service + req_vas_list: SelectedServiceList = service_req.selected_vas_list + + # Create a list of tuples, with each tuple containing the service ID and the + # associated parameter set IDs of an offered service. + offered_id_pairs: List[(int, int)] = [] + for offered_service in self.comm_session.offered_services_v20: + for parameter_set in offered_service.parameter_sets: + offered_id_pairs.append((offered_service.service.id, parameter_set.id)) + + # Let's first check if the (service ID, parameter set ID)-pair of the selected + # energy service is valid + if ( + req_energy_service.service_id, + req_energy_service.parameter_set_id, + ) not in offered_id_pairs: + return ( + False, + "Invalid selected pair of energy transfer service ID " + f"'{req_energy_service.service_id}' and parameter set ID " + f"'{req_energy_service.parameter_set_id}' (not offered by SECC)", + ResponseCode.FAILED_NO_ENERGY_TRANSFER_SERVICE_SELECTED, + ) + + # Let's check if the (service ID, parameter set ID)-pair of all selected + # value-added services (VAS) are valid (if the EVCC selected any VAS) + if req_vas_list: + for vas in req_vas_list.selected_services: + if (vas.service_id, vas.parameter_set_id) not in offered_id_pairs: + return ( + False, + "Invalid selected pair of value-added service ID " + f"'{vas.service_id}' and parameter set ID " + f"'{vas.parameter_set_id}' (not offered by SECC)", + ResponseCode.FAILED_SERVICE_SELECTION_INVALID, + ) + + # If all selected services are valid, let's add the information about the + # parameter set (not just the ID) to each selected service + for offered_service in self.comm_session.offered_services_v20: + if req_energy_service.service_id == offered_service.service.id: + for parameter_set in offered_service.parameter_sets: + if req_energy_service.parameter_set_id == parameter_set.id: + self.comm_session.selected_energy_service = ( + SelectedEnergyService( + service=ServiceV20.get_by_id( + req_energy_service.service_id + ), + is_free=offered_service.is_free, + parameter_set=parameter_set, + ) + ) + + # Set the control mode for the comm_session object + for param in parameter_set.parameters: + if param.name == ParameterName.CONTROL_MODE: + self.comm_session.control_mode = ControlMode( + param.int_value + ) + + break + continue + + if req_vas_list: + for vas in req_vas_list.selected_services: + if req_energy_service.service_id == offered_service.service.id: + for parameter_set in offered_service.parameter_sets: + if req_energy_service.parameter_set_id == parameter_set.id: + self.comm_session.selected_vas_list_v20.append( + SelectedVAS( + service=ServiceV20.get_by_id(vas.service_id), + is_free=offered_service.is_free, + parameter_set=parameter_set, + ) + ) + break + + # TODO Implement [V2G20-1956] and [V2G20-1644] (ServiceRenegotiationSupported) + # TODO Check for [V2G20-1985] + + return True, "", None + + +class ScheduleExchange(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes a + ScheduleExchangeReq from the EVCC. + + The EVCC may send one of the following requests in this state: + 1. ScheduleExchangeReq + 2. DCCableCheckReq + 3. PowerDeliveryReq + 3. SessionStopReq + + Upon first initialisation of this state, we expect a ScheduleExchangeReq + but after that, the next possible request could be another ScheduleExchangeReq, + a DCCableCheckReq, a PowerDeliveryReq or a SessionStopReq. This means that we need + to remain in this state until we receive the next message in the sequence. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + msg = self.check_msg_v20( + message, + [ScheduleExchangeReq, DCCableCheckReq, PowerDeliveryReq, SessionStopReq], + False, + ) + if not msg: + return + + if isinstance(msg, DCCableCheckReq): + DCCableCheck(self.comm_session).process_message(message) + return + + if isinstance(msg, PowerDeliveryReq): + PowerDelivery(self.comm_session).process_message(message) + return + + if isinstance(msg, SessionStopReq): + SessionStop(self.comm_session).process_message(message) + return + + schedule_exchange_req: ScheduleExchangeReq = msg + + scheduled_params, dynamic_params = None, None + evse_processing = Processing.ONGOING + if self.comm_session.control_mode == ControlMode.SCHEDULED: + scheduled_params = ( + self.comm_session.evse_controller.get_scheduled_se_params( + self.comm_session.selected_energy_service, schedule_exchange_req + ) ) + if scheduled_params: + evse_processing = Processing.FINISHED - self.create_next_message( - None, - auth_res, - Timeouts.V2G_SECC_SEQUENCE_TIMEOUT, - Namespace.ISO_V20_COMMON_MSG, + if self.comm_session.control_mode == ControlMode.DYNAMIC: + dynamic_params = self.comm_session.evse_controller.get_dynamic_se_params( + self.comm_session.selected_energy_service, schedule_exchange_req ) + if dynamic_params: + evse_processing = Processing.FINISHED - if auth_status == Processing.FINISHED: - self.expecting_authorization_req = False - else: - self.expecting_authorization_req = True + schedule_exchange_res = ScheduleExchangeRes( + header=MessageHeader( + session_id=self.comm_session.session_id, timestamp=time.time() + ), + response_code=ResponseCode.OK, + evse_processing=evse_processing, + scheduled_params=scheduled_params, + dynamic_params=dynamic_params, + ) + + # We don't know what request will come next (which state to transition to), + # unless the schedule parameters are ready and we're in AC charging. + # Even in DC charging the sequence is not 100% clear as the EVCC could skip + # DCCableCheck and DCPreCharge and go straight to PowerDelivery (Pause, Standby) + next_state = None + if ( + evse_processing == Processing.FINISHED + and self.comm_session.selected_energy_service.service + in (ServiceV20.AC, ServiceV20.AC_BPT) + ): + next_state = PowerDelivery + self.create_next_message( + next_state, + schedule_exchange_res, + Timeouts.V2G_SECC_SEQUENCE_TIMEOUT, + Namespace.ISO_V20_COMMON_MSG, + ISOV20PayloadTypes.SCHEDULE_RENEGOTIATION, + ) -class ServiceDiscovery(StateSECC): + +class PowerDelivery(StateSECC): """ The ISO 15118-20 state in which the SECC processes a - ServiceDiscoveryReq from the EVCC. + PowerDeliveryReq from the EVCC. """ def __init__(self, comm_session: SECCCommunicationSession): @@ -354,7 +853,7 @@ def process_message( V2GMessageV20, ], ): - raise NotImplementedError("ServiceDiscovery not yet implemented") + raise NotImplementedError("PowerDelivery not yet implemented") class SessionStop(StateSECC): @@ -383,6 +882,212 @@ def process_message( # ============================================================================ +class ACChargeParameterDiscovery(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes an + ACChargeParameterDiscoveryReq from the EVCC. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + raise NotImplementedError("ACChargeParameterDiscovery not yet implemented") + + +class ACChargeLoop(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes an + ACChargeLoopReq from the EVCC. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + raise NotImplementedError("ACChargeLoop not yet implemented") + + # ============================================================================ # | DC-SPECIFIC EVCC STATES - ISO 15118-20 | # ============================================================================ + + +class DCChargeParameterDiscovery(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes a + DCChargeParameterDiscoveryReq from the EVCC. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + msg = self.check_msg_v20( + message, [DCChargeParameterDiscoveryReq, SessionStopReq], False + ) + if not msg: + return + + if isinstance(msg, SessionStopReq): + SessionStop(self.comm_session).process_message(message) + return + + dc_cpd_req: DCChargeParameterDiscoveryReq = msg + + charge_params = self.comm_session.evse_controller.get_charge_params_v20( + self.comm_session.selected_energy_service + ) + + energy_service = self.comm_session.selected_energy_service.service + dc_params, bpt_dc_params = None, None + + if energy_service == ServiceV20.DC and self.charge_parameter_valid( + dc_cpd_req.dc_params + ): + dc_params = charge_params + elif energy_service == ServiceV20.DC_BPT and self.charge_parameter_valid( + dc_cpd_req.bpt_dc_params + ): + bpt_dc_params = charge_params + else: + self.stop_state_machine( + f"Invalid charge parameter for service {energy_service}", + message, + ResponseCode.FAILED_WRONG_CHARGE_PARAMETER, + ) + return + + dc_cpd_res = DCChargeParameterDiscoveryRes( + header=MessageHeader( + session_id=self.comm_session.session_id, timestamp=time.time() + ), + response_code=ResponseCode.OK, + dc_params=dc_params, + bpt_dc_params=bpt_dc_params, + ) + + self.create_next_message( + ScheduleExchange, + dc_cpd_res, + Timeouts.V2G_SECC_SEQUENCE_TIMEOUT, + Namespace.ISO_V20_DC, + ISOV20PayloadTypes.DC_MAINSTREAM, + ) + + def charge_parameter_valid( + self, + dc_charge_params: Union[ + DCChargeParameterDiscoveryReqParams, BPTDCChargeParameterDiscoveryReqParams + ], + ) -> bool: + # TODO Implement [V2G20-2272] (FAILED_WrongChargeParameter) + return True + + +class DCCableCheck(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes a + DCCableCheckReq from the EVCC. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + raise NotImplementedError("DCCableCheck not yet implemented") + + +class DCPreCharge(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes a + DCPreChargeReq from the EVCC. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + raise NotImplementedError("DCPreCharge not yet implemented") + + +class DCChargeLoop(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes a + DCChargeLoopReq from the EVCC. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + raise NotImplementedError("DCChargeLoop not yet implemented") + + +class DCWeldingDetection(StateSECC): + """ + The ISO 15118-20 state in which the SECC processes a + DCWeldingDetectionReq from the EVCC. + """ + + def __init__(self, comm_session: SECCCommunicationSession): + super().__init__(comm_session, Timeouts.V2G_EVCC_COMMUNICATION_SETUP_TIMEOUT) + + def process_message( + self, + message: Union[ + SupportedAppProtocolReq, + SupportedAppProtocolRes, + V2GMessageV2, + V2GMessageV20, + ], + ): + raise NotImplementedError("DCWeldingDetection not yet implemented") diff --git a/iso15118/secc/states/secc_state.py b/iso15118/secc/states/secc_state.py index 5d31e2ea..7ade7355 100644 --- a/iso15118/secc/states/secc_state.py +++ b/iso15118/secc/states/secc_state.py @@ -14,23 +14,21 @@ SupportedAppProtocolRes, ) from iso15118.shared.messages.enums import Namespace -from iso15118.shared.messages.iso15118_2.body import BodyBase from iso15118.shared.messages.iso15118_2.body import ( + BodyBase, + get_msg_type, SessionSetupReq as SessionSetupReqV2, ) -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_types import ( - ResponseCode as ResponseCodeV20, -) -from iso15118.shared.messages.iso15118_20.common_types import ( + V2GRequest, V2GMessage as V2GMessageV20, + ResponseCode as ResponseCodeV20, ) -from iso15118.shared.messages.iso15118_20.common_types import V2GRequest from iso15118.shared.notifications import StopNotification from iso15118.shared.states import State, Terminate @@ -257,11 +255,13 @@ def stop_state_machine( error_res.response_code = response_code self.create_next_message(Terminate, error_res, 0, Namespace.ISO_V2_MSG_DEF) elif isinstance(faulty_request, V2GRequest): - error_res, namespace = self.comm_session.failed_responses_isov20.get( - type(faulty_request) - ) + ( + error_res, + namespace, + payload_type, + ) = self.comm_session.failed_responses_isov20.get(type(faulty_request)) error_res.response_code = response_code - self.create_next_message(Terminate, error_res, 0, namespace) + self.create_next_message(Terminate, error_res, 0, namespace, payload_type) elif isinstance(faulty_request, SupportedAppProtocolReq): error_res = SupportedAppProtocolRes(response_code=response_code) diff --git a/iso15118/shared/EXICodec.jar b/iso15118/shared/EXICodec.jar index d782dc94..090ca3f4 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 4e8a829a..e87ec0db 100644 --- a/iso15118/shared/comm_session.py +++ b/iso15118/shared/comm_session.py @@ -25,12 +25,24 @@ SupportedAppProtocolReq, SupportedAppProtocolRes, ) -from iso15118.shared.messages.enums import Namespace, Protocol +from iso15118.shared.messages.enums import ( + Namespace, + Protocol, + DINPayloadTypes, + ISOV2PayloadTypes, + ISOV20PayloadTypes, + ControlMode, +) from iso15118.shared.messages.iso15118_2.datatypes import EnergyTransferModeEnum from iso15118.shared.messages.iso15118_2.datatypes import ( SelectedService as SelectedServiceV2, ) from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 +from iso15118.shared.messages.iso15118_20.common_messages import ( + OfferedService as OfferedServiceV20, + SelectedEnergyService, + SelectedVAS, +) from iso15118.shared.messages.iso15118_20.common_types import ( V2GMessage as V2GMessageV20, ) @@ -95,7 +107,10 @@ def __init__( self.comm_session = comm_session self.current_state = start_state(comm_session) - def get_exi_ns(self) -> str: + def get_exi_ns( + self, + payload_type: Union[DINPayloadTypes, ISOV2PayloadTypes, ISOV20PayloadTypes], + ) -> str: """ Provides the right protocol namespace for the EXI decoder. In DIN SPEC 70121 and ISO 15118-2, all messages are defined @@ -114,13 +129,25 @@ def get_exi_ns(self) -> str: return Namespace.ISO_V2_MSG_DEF elif self.comm_session.protocol == Protocol.DIN_SPEC_70121: return Namespace.DIN_MSG_BODY - elif str(self.current_state).startswith("AC"): + elif ( + self.comm_session.protocol.ns.startswith(Namespace.ISO_V20_BASE) + and payload_type.value == ISOV20PayloadTypes.AC_MAINSTREAM + ): return Namespace.ISO_V20_AC - elif str(self.current_state).startswith("DC"): + elif ( + self.comm_session.protocol.ns.startswith(Namespace.ISO_V20_BASE) + and payload_type.value == ISOV20PayloadTypes.DC_MAINSTREAM + ): return Namespace.ISO_V20_DC - elif str(self.current_state).startswith("WPT"): + elif ( + self.comm_session.protocol.ns.startswith(Namespace.ISO_V20_BASE) + and payload_type.value == ISOV20PayloadTypes.WPT_MAINSTREAM + ): return Namespace.ISO_V20_WPT - elif str(self.current_state).startswith("ACDP"): + elif ( + self.comm_session.protocol.ns.startswith(Namespace.ISO_V20_BASE) + and payload_type.value == ISOV20PayloadTypes.ACDP_MAINSTREAM + ): return Namespace.ISO_V20_ACDP else: return Namespace.ISO_V20_COMMON_MSG @@ -176,7 +203,9 @@ def process_message(self, message: bytes): None, ] = None try: - decoded_message = from_exi(v2gtp_msg.payload, self.get_exi_ns()) + decoded_message = from_exi( + v2gtp_msg.payload, self.get_exi_ns(v2gtp_msg.payload_type) + ) except EXIDecodingError as exc: logger.exception(f"{exc}") raise exc @@ -275,12 +304,24 @@ def __init__( self.session_id: str = "" # Mutually agreed-upon ISO 15118 application protocol as result of SAP self.chosen_protocol: str = "" - # The services offered by the SECC and selected by the EVCC - self.selected_services: List[SelectedServiceV2] = [] - # Selected energy modes helps to choose AC or DC specific parameters + # Whether the SECC supports service renegotiation (ISO 15118-20) + self.service_renegotiation_supported: bool = False + # The services which the SECC offers (ISO 15118-20) + self.offered_services_v20: List[OfferedServiceV20] = [] + # The value-added services which the EVCC selected (ISO 15118-20) + self.selected_vas_list_v20: List[SelectedVAS] = [] + # The value-added services which the EVCC selected (ISO 15118-2) + self.selected_vas_list_v2: List[SelectedServiceV2] = [] + # The energy service the EVCC selected (ISO 15118-20) + self.selected_energy_service: Optional[SelectedEnergyService] = None + # The energy mode the EVCC selected (ISO 15118-2) self.selected_energy_mode: Optional[EnergyTransferModeEnum] = None # The SAScheduleTuple element the EVCC chose (referenced by ID) self.selected_schedule: Optional[int] = None + # The control mode used for this session (Scheduled or Dynamic). In ISO 15118-2, + # there is only Scheduled, in -20 we have both and need to choose certain + # datatypes of messages based on which control mode was chosen + self.control_mode: Optional[ControlMode] = None # Contains info whether the communication session is stopped successfully (True) # or due to a failure (False), plus additional info regarding the reason behind. self.stop_reason: Optional[StopNotification] = None diff --git a/iso15118/shared/exi_codec.py b/iso15118/shared/exi_codec.py index b59a98d6..f4c2a2ec 100644 --- a/iso15118/shared/exi_codec.py +++ b/iso15118/shared/exi_codec.py @@ -1,8 +1,9 @@ import base64 import json import logging -from base64 import b64decode, b64encode -from typing import Union + +from json import JSONDecodeError +from typing import Union, List from pydantic import ValidationError @@ -15,8 +16,22 @@ ) from iso15118.shared.messages.enums import Namespace from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2 +from iso15118.shared.messages.iso15118_20.ac import ( + ACChargeParameterDiscoveryReq, + ACChargeParameterDiscoveryRes, +) from iso15118.shared.messages.iso15118_20.common_messages import ( AuthorizationReq as AuthorizationReqV20, + ServiceDetailReq, + ServiceDetailRes, + ServiceSelectionReq, + ServiceSelectionRes, + ScheduleExchangeReq, + ScheduleExchangeRes, + PowerDeliveryReq, + PowerDeliveryRes, + SessionStopReq, + SessionStopRes, ) from iso15118.shared.messages.iso15118_20.common_messages import ( AuthorizationRes, @@ -32,6 +47,18 @@ from iso15118.shared.messages.iso15118_20.common_types import ( V2GMessage as V2GMessageV20, ) +from iso15118.shared.messages.iso15118_20.dc import ( + DCChargeParameterDiscoveryReq, + DCChargeParameterDiscoveryRes, + DCChargeLoopReq, + DCChargeLoopRes, + DCCableCheckReq, + DCCableCheckRes, + DCPreChargeReq, + DCPreChargeRes, + DCWeldingDetectionReq, + DCWeldingDetectionRes, +) from iso15118.shared.messages.xmldsig import SignedInfo from iso15118.shared.settings import MESSAGE_LOG_EXI, MESSAGE_LOG_JSON @@ -273,13 +300,34 @@ def from_exi( "SessionSetupRes": SessionSetupRes, "AuthorizationSetupReq": AuthorizationSetupReq, "AuthorizationSetupRes": AuthorizationSetupRes, + "CertificateInstallationReq": CertificateInstallationReq, + "CertificateInstallationRes": CertificateInstallationRes, "AuthorizationReq": AuthorizationReqV20, "AuthorizationRes": AuthorizationRes, "ServiceDiscoveryReq": ServiceDiscoveryReq, "ServiceDiscoveryRes": ServiceDiscoveryRes, - "CertificateInstallationReq": CertificateInstallationReq, - "CertificateInstallationRes": CertificateInstallationRes, - # TODO add all the other message types and states + "ServiceDetailReq": ServiceDetailReq, + "ServiceDetailRes": ServiceDetailRes, + "ServiceSelectionReq": ServiceSelectionReq, + "ServiceSelectionRes": ServiceSelectionRes, + "AC_ChargeParameterDiscoveryReq": ACChargeParameterDiscoveryReq, + "AC_ChargeParameterDiscoveryRes": ACChargeParameterDiscoveryRes, + "DC_ChargeParameterDiscoveryReq": DCChargeParameterDiscoveryReq, + "DC_ChargeParameterDiscoveryRes": DCChargeParameterDiscoveryRes, + "ScheduleExchangeReq": ScheduleExchangeReq, + "ScheduleExchangeRes": ScheduleExchangeRes, + "DC_CableCheckReq": DCCableCheckReq, + "DC_CableCheckRes": DCCableCheckRes, + "DC_PreChargeReq": DCPreChargeReq, + "DC_PreChargeRes": DCPreChargeRes, + "PowerDeliveryReq": PowerDeliveryReq, + "PowerDeliveryRes": PowerDeliveryRes, + "DC_ChargeLoopReq": DCChargeLoopReq, + "DC_ChargeLoopRes": DCChargeLoopRes, + "DC_WeldingDetectionReq": DCWeldingDetectionReq, + "DC_WeldingDetectionRes": DCWeldingDetectionRes, + "SessionStopReq": SessionStopReq, + "SessionStopRes": SessionStopRes, } msg_class = msg_classes_dict.get(msg_name) if not msg_class: @@ -293,7 +341,9 @@ def from_exi( # TODO Add support for DIN SPEC 70121 - raise EXIDecodingError("Can't identify protocol to use for decoding") + raise EXIDecodingError( + "EXI decoding error: 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}. " diff --git a/iso15118/shared/messages/enums.py b/iso15118/shared/messages/enums.py index 506bbda5..eb3fb2df 100644 --- a/iso15118/shared/messages/enums.py +++ b/iso15118/shared/messages/enums.py @@ -4,9 +4,18 @@ logger = logging.getLogger(__name__) +# For XSD type xs:unsignedLong with value range [0..18446744073709551615] +UINT_64_MAX = 2 ** 64 - 1 +# For XSD type xs:unsignedInt with value range [0..4294967296] UINT_32_MAX = 2 ** 32 - 1 +# For XSD type xs:unsignedShort with value range [0..65535] +UINT_16_MAX = 2 ** 16 - 1 +# For XSD type xs:unsignedByte with value range [0..255] +UINT_8_MAX = 2 ** 8 - 1 +# For XSD type xs:short with value range [-32768..32767] INT_16_MAX = 2 ** 15 - 1 INT_16_MIN = -(2 ** 15) +# For XSD type xs:byte with value range [-128..127] INT_8_MAX = 2 ** 7 - 1 INT_8_MIN = -(2 ** 7) @@ -205,3 +214,149 @@ def v20_namespaces(cls) -> List[str]: for protocol in cls if "urn:iso:std:iso:15118:-20" in protocol.namespace ] + + +class ServiceV20(Enum): + """ + Available services in ISO 15118-20. The values of these enum members are tuples, + with the first tuple entry being the service ID (given as an int) and the second + tuple entry being the according service name (given as string). + + See Table 204 in section 8.4.3.1 of ISO 15118-20 + """ + + AC = (1, "AC") + DC = (2, "DC") + WPT = (3, "WPT") + DC_ACDP = (4, "DC_ACDP") + AC_BPT = (5, "AC_BPT") + DC_BPT = (6, "DC_BPT") + DC_ACDP_BPT = (7, "DC_ACDP_BPT") + INTERNET = (65, "Internet") + PARKING_STATUS = (66, "ParkingStatus") + + def __init__( + self, + service_id: int, + service_name: str, + ): + """ + The value of each enum member is a tuple, where the first tuple entry + is the associated protocol namespace (ns) and the second tuple entry are + the associated payload types, given as an enum itself. + """ + self.service_id = service_id + self.service_name = service_name + + @classmethod + def get_by_id(cls, service_id: int) -> "ServiceV20": + """ + Returns the ServiceV20 enum member given a service ID. + + Raises: + ValueError if an invalid service ID is provided. + """ + if service_id == 1: + return cls.AC + elif service_id == 2: + return cls.DC + elif service_id == 3: + return cls.WPT + elif service_id == 4: + return cls.DC_ACDP + elif service_id == 5: + return cls.AC_BPT + elif service_id == 6: + return cls.DC_BPT + elif service_id == 7: + return cls.DC_ACDP_BPT + elif service_id == 65: + return cls.INTERNET + elif service_id == 66: + return cls.PARKING_STATUS + else: + raise ValueError(f"Invalid service ID {service_id}") + + @property + def id(self) -> int: + return self.service_id + + @property + def name(self) -> str: + return self.service_name + + +class ParameterName(str, Enum): + CONNECTOR = "Connector" + CONTROL_MODE = "ControlMode" + EVSE_NOMINAL_VOLTAGE = "EVSENominalVoltage" + MOBILITY_NEEDS_MODE = "MobilityNeedsMode" + PRICING = "Pricing" + BPT_CHANNEL = "BPTChannel" + GENERATOR_MODE = "GeneratorMode" + GRID_CODE_ISLANDING_DETECTION_MODE = "GridCodeIslandingDetectionMethod" + + +class ACConnector(IntEnum): + """See Table 205 in section 8.4.3.2.2 of ISO 15118-20""" + + SINGLE_PHASE = 1 + THREE_PHASE = 2 + + +class DCConnector(IntEnum): + """See Table 207 in section 8.4.3.2.3 of ISO 15118-20""" + + CORE = 1 + EXTENDED = 2 + DUAL2 = 3 + DUAL4 = 4 + + +class ControlMode(IntEnum): + """See e.g. Table 205 in section 8.4.3.2.2 of ISO 15118-20""" + + SCHEDULED = 1 + DYNAMIC = 2 + + +class MobilityNeedsMode(IntEnum): + """See e.g. Table 205 in section 8.4.3.2.2 of ISO 15118-20""" + + EVCC_ONLY = 1 + EVCC_AND_SECC = 2 + + +class Pricing(IntEnum): + """See e.g. Table 205 in section 8.4.3.2.2 of ISO 15118-20""" + + NONE = 0 + ABSOLUTE = 1 + LEVELS = 2 + + +class BPTChannel(IntEnum): + """See e.g. Table 206 in section 8.4.3.2.2.1 of ISO 15118-20""" + + UNIFIED = 1 + SEPARATED = 2 + + +class GeneratorMode(IntEnum): + """See e.g. Table 206 in section 8.4.3.2.2.1 of ISO 15118-20""" + + GRID_FOLLOWING = 1 + GRID_FORMING = 2 + + +class GridCodeIslandingDetectionMode(IntEnum): + """See e.g. Table 206 in section 8.4.3.2.2.1 of ISO 15118-20""" + + ACTIVE = 1 + PASSIVE = 2 + + +class PriceAlgorithm(str, Enum): + POWER = "urn:iso:std:iso:15118:-20:PriceAlgorithm:1-Power" + PEAK_POWER = "urn:iso:std:iso:15118:-20:PriceAlgorithm:2-PeakPower" + STACKED_POWER = "urn:iso:std:iso:15118:-20:PriceAlgorithm:3-StackedEnergy" diff --git a/iso15118/shared/messages/iso15118_20/ac.py b/iso15118/shared/messages/iso15118_20/ac.py index 4a1549ec..847fd35e 100644 --- a/iso15118/shared/messages/iso15118_20/ac.py +++ b/iso15118/shared/messages/iso15118_20/ac.py @@ -161,7 +161,7 @@ class ScheduledACChargeLoopReqParams(ScheduledChargeLoopReqParams): ) -class ScheduledACChargeLoopResParamsParams(ScheduledChargeLoopResParams): +class ScheduledACChargeLoopResParams(ScheduledChargeLoopResParams): """See section 8.3.5.4.6 in ISO 15118-20""" evse_target_active_power: RationalNumber = Field( @@ -216,7 +216,7 @@ class BPTScheduledACChargeLoopReqParams(ScheduledACChargeLoopReqParams): ) -class BPTScheduledACChargeLoopResParams(ScheduledACChargeLoopResParamsParams): +class BPTScheduledACChargeLoopResParams(ScheduledACChargeLoopResParams): """See section 8.3.5.4.7.6 in ISO 15118-20""" @@ -432,7 +432,7 @@ class ACChargeLoopRes(ChargeLoopRes): """See section 8.3.4.4.3.3 in ISO 15118-20""" evse_target_frequency: RationalNumber = Field(None, alias="EVSETargetFrequency") - scheduled_ac_charge_loop_res: ScheduledACChargeLoopResParamsParams = Field( + scheduled_ac_charge_loop_res: ScheduledACChargeLoopResParams = Field( None, alias="Scheduled_AC_CLResControlMode" ) dynamic_ac_charge_loop_res: DynamicACChargeLoopRes = Field( diff --git a/iso15118/shared/messages/iso15118_20/common_messages.py b/iso15118/shared/messages/iso15118_20/common_messages.py index 7a61b7b6..56b7ca5d 100644 --- a/iso15118/shared/messages/iso15118_20/common_messages.py +++ b/iso15118/shared/messages/iso15118_20/common_messages.py @@ -10,26 +10,36 @@ (or class) that matches the definitions in the XSD schema, including the XSD element names by using the 'alias' attribute. """ - +from dataclasses import dataclass from enum import Enum from typing import List from pydantic import Field, root_validator, validator from iso15118.shared.messages import BaseModel -from iso15118.shared.messages.enums import AuthEnum +from iso15118.shared.messages.enums import ( + AuthEnum, + INT_8_MIN, + INT_8_MAX, + INT_16_MAX, + INT_16_MIN, + ServiceV20, + UINT_16_MAX, + UINT_8_MAX, +) from iso15118.shared.messages.iso15118_20.common_types import ( UINT_32_MAX, - Certificate, EVSEStatus, - Identifier, MeterInfo, Processing, RationalNumber, Receipt, - RootCertificateID, V2GRequest, V2GResponse, + RootCertificateIDList, + NumericID, + Name, + Description, ) from iso15118.shared.validators import one_field_must_be_set @@ -44,16 +54,24 @@ class ECDHCurve(str, Enum): x448 = "X448" -class EMAIDList(BaseModel): +class EMAID(BaseModel): """See Annex C.1 in ISO 15118-20""" - emaids: List[Identifier] = Field(..., max_items=8, alias="EMAID") + emaid: str = Field(..., max_length=255, alias="EMAID") + + +class Certificate(BaseModel): + """A DER encoded X.509 certificate""" + + certificate: bytes = Field(..., max_length=800, alias="Certificate") class SubCertificates(BaseModel): - """See Annex C.1 or V2G_CI_CommonTypes.xsd in ISO 15118-20""" + """A list of DER encoded X.509 certificates""" - certificates: List[Certificate] = Field(..., max_items=3, alias="Certificate") + certificate: List[bytes] = Field( + ..., max_length=1600, max_items=3, alias="Certificate" + ) class CertificateChain(BaseModel): @@ -61,7 +79,7 @@ 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: Certificate = Field(..., alias="Certificate") + certificate: bytes = Field(..., max_length=800, alias="Certificate") sub_certificates: SubCertificates = Field(None, alias="SubCertificates") @@ -73,7 +91,7 @@ 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: Certificate = Field(..., alias="Certificate") + certificate: bytes = Field(..., max_length=800, alias="Certificate") sub_certificates: SubCertificates = Field(None, alias="SubCertificates") def __str__(self): @@ -85,7 +103,7 @@ 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: Certificate = Field(..., alias="Certificate") + certificate: bytes = Field(..., max_length=800, alias="Certificate") sub_certificates: SubCertificates = Field(..., alias="SubCertificates") @@ -106,7 +124,7 @@ class AuthorizationSetupReq(V2GRequest): class ProviderID(BaseModel): - provider_id: str = Field(..., max_length=80, alias="ProviderID") + provider_id: Name = Field(..., alias="ProviderID") class PnCAuthSetupResParams(BaseModel): @@ -175,6 +193,11 @@ class PnCAuthReqParams(BaseModel): ..., alias="ContractCertificateChain" ) + def __str__(self): + # We need to sign this element, which means it will be EXI encoded and we need + # its XSD-conform name + return "PnC_AReqAuthorizationMode" + class EIMAuthReqParams(BaseModel): """ @@ -221,29 +244,29 @@ class AuthorizationRes(V2GResponse): evse_processing: Processing = Field(..., alias="EVSEProcessing") -class ServiceIdList(BaseModel): +class ServiceIDList(BaseModel): """See section 8.3.5.3.29 in ISO 15118-20""" - service_id: List[int] = Field(..., max_items=16, alias="ServiceID") + service_ids: List[int] = Field(..., max_items=16, alias="ServiceID") class ServiceDiscoveryReq(V2GRequest): """See section 8.3.4.3.4.2 in ISO 15118-20""" - supported_service_ids: ServiceIdList = Field(..., alias="SupportedServiceIDs") + supported_service_ids: ServiceIDList = Field(None, alias="SupportedServiceIDs") -class ServiceDetails(BaseModel): +class Service(BaseModel): """See section 8.3.5.3.1 in ISO 15118-20""" service_id: int = Field(..., alias="ServiceID") free_service: bool = Field(..., alias="FreeService") -class Service(BaseModel): +class ServiceList(BaseModel): """See section 8.3.5.3.2 in ISO 15118-20""" - service_details: ServiceDetails = Field(..., alias="Service") + services: List[Service] = Field(..., max_items=8, alias="Service") class ServiceDiscoveryRes(V2GResponse): @@ -252,10 +275,8 @@ class ServiceDiscoveryRes(V2GResponse): service_renegotiation_supported: bool = Field( ..., alias="ServiceRenegotiationSupported" ) - energy_transfer_service_list: List[Service] = Field( - ..., max_items=8, alias="EnergyTransferServiceList" - ) - vas_list: List[Service] = Field(None, max_items=8, alias="VASList") + energy_service_list: ServiceList = Field(..., alias="EnergyTransferServiceList") + vas_list: ServiceList = Field(None, alias="VASList") class ServiceDetailReq(V2GRequest): @@ -269,14 +290,15 @@ class Parameter(BaseModel): # 'Name' is actually an XML attribute, but JSON (our serialisation method) # doesn't have attributes. The EXI codec has to en-/decode accordingly. - name: str = Field(..., alias="Name") + name: Name = Field(..., alias="Name") bool_value: bool = Field(None, alias="boolValue") # XSD type byte with value range [-128..127] - byte_value: int = Field(None, ge=-128, le=127, alias="byteValue") - short_value: int = Field(None, ge=0, le=65535, alias="shortValue") + byte_value: int = Field(None, ge=INT_8_MIN, le=INT_8_MAX, alias="byteValue") + # XSD type short (16 bit integer) with value range [-32768..32767] + short_value: int = Field(None, ge=INT_16_MIN, le=INT_16_MAX, alias="shortValue") int_value: int = Field(None, alias="intValue") rational_number: RationalNumber = Field(None, alias="rationalNumber") - finite_str: str = Field(None, alias="finiteString") + finite_str: Name = Field(None, alias="finiteString") @root_validator(pre=True) def at_least_one_parameter_value(cls, values): @@ -313,14 +335,14 @@ def at_least_one_parameter_value(cls, values): class ParameterSet(BaseModel): """See section 8.3.5.3.22 in ISO 15118-20""" - parameter_set_id: int = Field(..., alias="ParameterSetID") - parameter: List[Parameter] = Field(..., max_items=32, alias="Parameter") + id: int = Field(..., alias="ParameterSetID") + parameters: List[Parameter] = Field(..., max_items=32, alias="Parameter") class ServiceParameterList(BaseModel): """See section 8.3.5.3.21 in ISO 15118-20""" - parameter_set: List[ParameterSet] = Field(..., max_items=32, alias="ParameterSet") + parameter_sets: List[ParameterSet] = Field(..., max_items=32, alias="ParameterSet") class ServiceDetailRes(V2GResponse): @@ -342,7 +364,7 @@ class SelectedService(BaseModel): class SelectedServiceList(BaseModel): """See section 8.3.5.3.24 in ISO 15118-20""" - selected_service: List[SelectedService] = Field( + selected_services: List[SelectedService] = Field( ..., max_items=16, alias="SelectedService" ) @@ -350,10 +372,10 @@ class SelectedServiceList(BaseModel): class ServiceSelectionReq(V2GRequest): """See section 8.3.4.3.6.2 in ISO 15118-20""" - selected_energy_transfer_service: SelectedService = Field( + selected_energy_service: SelectedService = Field( ..., alias="SelectedEnergyTransferService" ) - selected_vas_list: SelectedService = Field(None, alias="SelectedVASList") + selected_vas_list: SelectedServiceList = Field(None, alias="SelectedVASList") class ServiceSelectionRes(V2GResponse): @@ -370,7 +392,7 @@ class EVPowerScheduleEntry(BaseModel): class EVPowerScheduleEntryList(BaseModel): """See section 8.3.5.3.43 in ISO 15118-20""" - ev_power_schedule_entry: List[EVPowerScheduleEntry] = Field( + ev_power_schedule_entries: List[EVPowerScheduleEntry] = Field( ..., max_items=1024, alias="EVPowerScheduleEntry" ) @@ -395,13 +417,13 @@ class EVPriceRuleStack(BaseModel): """See section 8.3.5.3.47 in ISO 15118-20""" duration: int = Field(..., alias="Duration") - ev_price_rule: List[EVPriceRule] = Field(..., max_items=8, alias="EVPriceRule") + ev_price_rules: List[EVPriceRule] = Field(..., max_items=8, alias="EVPriceRule") class EVPriceRuleStackList(BaseModel): """See section 8.3.5.3.46 in ISO 15118-20""" - ev_price_rule_stack: List[EVPriceRuleStack] = Field( + ev_price_rule_stacks: List[EVPriceRuleStack] = Field( ..., max_items=1024, alias="EVPriceRuleStack" ) @@ -427,7 +449,7 @@ class EVEnergyOffer(BaseModel): class ScheduledScheduleExchangeReqParams(BaseModel): """See section 8.3.5.3.14 in ISO 15118-20""" - departure_time: int = Field(None, alias="DepartureTime") + departure_time: int = Field(None, ge=0, le=UINT_32_MAX, alias="DepartureTime") ev_target_energy_request: RationalNumber = Field( None, alias="EVTargetEnergyRequest" ) @@ -439,7 +461,7 @@ class ScheduledScheduleExchangeReqParams(BaseModel): class DynamicScheduleExchangeReqParams(BaseModel): """See section 8.3.5.3.13 in ISO 15118-20""" - departure_time: int = Field(..., alias="DepartureTime") + departure_time: int = Field(..., ge=0, le=UINT_32_MAX, alias="DepartureTime") # XSD type byte with value range [0..100] min_soc: int = Field(None, ge=0, le=100, alias="MinimumSOC") # XSD type byte with value range [0..100] @@ -447,6 +469,28 @@ class DynamicScheduleExchangeReqParams(BaseModel): ev_target_energy_request: RationalNumber = Field(..., alias="EVTargetEnergyRequest") ev_max_energy_request: RationalNumber = Field(..., alias="EVMaximumEnergyRequest") ev_min_energy_request: RationalNumber = Field(..., alias="EVMinimumEnergyRequest") + ev_max_v2x_energy_request: RationalNumber = Field( + None, alias="EVMaximumV2XEnergyRequest" + ) + ev_min_v2x_energy_request: RationalNumber = Field( + None, alias="EVMinimumV2XEnergyRequest" + ) + + @root_validator(pre=True) + def both_v2x_fields_must_be_set(cls, values): + max_v2g, min_v2x = ( + values.get("ev_max_v2x_energy_request"), + values.get("ev_min_v2x_energy_request"), + ) + + if (max_v2g and not min_v2x) or (min_v2x and not max_v2g): + raise ValueError( + "EVMaximumV2XEnergyRequest and EVMinimumV2XEnergyRequest of type " + "Dynamic_SEReqControlModeType must either be both set or both omitted. " + "Only one of them was set ([V2G20-2681])" + ) + + return values class ScheduleExchangeReq(V2GRequest): @@ -455,17 +499,17 @@ class ScheduleExchangeReq(V2GRequest): max_supporting_points: int = Field( ..., ge=12, le=1024, alias="MaximumSupportingPoints" ) - scheduled_se_req: ScheduledScheduleExchangeReqParams = Field( - ..., alias="Scheduled_SEReqControlMode" + scheduled_params: ScheduledScheduleExchangeReqParams = Field( + None, alias="Scheduled_SEReqControlMode" ) - dynamic_se_req: DynamicScheduleExchangeReqParams = Field( - ..., alias="Dynamic_SEReqControlMode" + dynamic_params: DynamicScheduleExchangeReqParams = Field( + None, alias="Dynamic_SEReqControlMode" ) @root_validator(pre=True) def either_scheduled_or_dynamic(cls, values): """ - Either scheduled_se_req or dynamic_se_req must be set, depending on + Either scheduled_params or dynamic_params must be set, depending on whether the charging process is governed by charging schedules or dynamic charging settings from the SECC. @@ -476,9 +520,9 @@ def either_scheduled_or_dynamic(cls, values): # pylint: disable=no-self-use if one_field_must_be_set( [ - "scheduled_se_req", + "scheduled_params", "Scheduled_SEReqControlMode", - "dynamic_se_req", + "dynamic_params", "Dynamic_SEReqControlMode", ], values, @@ -499,7 +543,7 @@ class PowerScheduleEntry(BaseModel): class PowerScheduleEntryList(BaseModel): """See section 8.3.5.3.19 in ISO 15118-20""" - power_schedule_entry: List[PowerScheduleEntry] = Field( + power_schedule_entries: List[PowerScheduleEntry] = Field( ..., max_items=1024, alias="PowerScheduleEntry" ) @@ -519,24 +563,22 @@ class PriceSchedule(BaseModel): """See sections 8.3.5.3.49 and 8.3.5.3.62 in ISO 15118-20""" time_anchor: int = Field(..., alias="TimeAnchor") - price_schedule_id: int = Field(..., ge=1, le=UINT_32_MAX, alias="PriceScheduleID") - price_schedule_description: str = Field( - None, max_length=160, alias="PriceScheduleDescription" - ) + schedule_id: NumericID = Field(..., alias="PriceScheduleID") + schedule_description: Description = Field(None, alias="PriceScheduleDescription") class PriceLevelScheduleEntry(BaseModel): """See section 8.3.5.3.64 in ISO 15118-20""" - duration: int = Field(..., alias="Duration") + duration: int = Field(..., ge=0, le=UINT_32_MAX, alias="Duration") # XSD type unsignedByte with value range [0..255] - price_level: int = Field(..., ge=0, le=255, alias="PriceLevel") + price_level: int = Field(..., ge=0, le=UINT_8_MAX, alias="PriceLevel") class PriceLevelScheduleEntryList(BaseModel): """See section 8.3.5.3.63 in ISO 15118-20""" - entry: List[PriceLevelScheduleEntry] = Field( + entries: List[PriceLevelScheduleEntry] = Field( ..., max_items=1024, alias="PriceLevelScheduleEntry" ) @@ -548,7 +590,7 @@ class PriceLevelSchedule(PriceSchedule): # doesn't have attributes. The EXI codec has to en-/decode accordingly. id: str = Field(None, max_length=255, alias="Id") # XSD type unsignedByte with value range [0..255] - num_price_levels: int = Field(..., ge=0, le=255, alias="NumberOfPriceLevels") + num_price_levels: int = Field(..., ge=0, le=UINT_8_MAX, alias="NumberOfPriceLevels") schedule_entries: PriceLevelScheduleEntryList = Field( ..., alias="PriceLevelScheduleEntries" ) @@ -557,8 +599,8 @@ class PriceLevelSchedule(PriceSchedule): class TaxRule(BaseModel): """See section 8.3.5.3.51 in ISO 15118-20""" - tax_rule_id: int = Field(..., ge=1, le=UINT_32_MAX, alias="TaxRuleID") - tax_rule_name: str = Field(None, max_length=80, alias="TaxRuleName") + tax_rule_id: NumericID = Field(..., alias="TaxRuleID") + tax_rule_name: Name = Field(None, alias="TaxRuleName") tax_rate: RationalNumber = Field(..., alias="TaxRate") tax_included_in_price: bool = Field(None, alias="TaxIncludedInPrice") applies_to_enery_fee: bool = Field(..., alias="AppliesToEnergyFee") @@ -576,11 +618,12 @@ class TaxRuleList(BaseModel): class PriceRule(BaseModel): """See section 8.3.5.3.54 in ISO 15118-20""" - price_rule_id: int = Field(..., ge=1, le=UINT_32_MAX, alias="PriceRuleID") energy_fee: RationalNumber = Field(..., alias="EnergyFee") parking_fee: RationalNumber = Field(None, alias="EnergyFee") - parking_fee_period: int = Field(None, alias="ParkingFeePeriod") - carbon_dioxide_emission: int = Field(None, alias="CarbonDioxideEmission") + parking_fee_period: int = Field(None, le=UINT_32_MAX, alias="ParkingFeePeriod") + carbon_dioxide_emission: int = Field( + None, le=UINT_16_MAX, alias="CarbonDioxideEmission" + ) # XSD type unsignedByte with value range [0..255] renewable_energy_percentage: int = Field( None, ge=0, le=255, alias="RenewableGenerationPercentage" @@ -591,17 +634,14 @@ class PriceRule(BaseModel): class PriceRuleStack(BaseModel): """See section 8.3.5.3.53 in ISO 15118-20""" - price_rule_stack_id: int = Field( - ..., ge=1, le=UINT_32_MAX, alias="PriceRuleStackID" - ) - duration: int = Field(..., alias="Duration") - price_rule: List[PriceRule] = Field(..., max_items=8, alias="PriceRule") + duration: int = Field(..., ge=0, le=UINT_32_MAX, alias="Duration") + price_rules: List[PriceRule] = Field(..., max_items=8, alias="PriceRule") class PriceRuleStackList(BaseModel): """See section 8.3.5.3.52 in ISO 15118-20""" - price_rule_stack: List[PriceRuleStack] = Field( + price_rule_stacks: List[PriceRuleStack] = Field( ..., max_items=1024, alias="PriceRuleStack" ) @@ -609,39 +649,33 @@ class PriceRuleStackList(BaseModel): class OverstayRule(BaseModel): """See section 8.3.5.3.56 in ISO 15118-20""" - overstay_rule_id: int = Field(..., ge=1, le=UINT_32_MAX, alias="OverstayRuleID") - overstay_rule_description: str = Field( - None, max_length=160, alias="OverstayRuleDescription" - ) - start_time: int = Field(..., alias="StartTime") - overstay_fee: RationalNumber = Field(..., alias="OverstayFee") - overstay_fee_period: int = Field(..., alias="OverstayFeePeriod") + description: Description = Field(None, alias="OverstayRuleDescription") + start_time: int = Field(..., ge=0, le=UINT_32_MAX, alias="StartTime") + fee: RationalNumber = Field(..., alias="OverstayFee") + fee_period: int = Field(..., ge=0, le=UINT_32_MAX, alias="OverstayFeePeriod") class OverstayRuleList(BaseModel): """See section 8.3.5.3.55 in ISO 15118-20""" - overstay_rule_list_id: int = Field( - ..., ge=1, le=UINT_32_MAX, alias="OverstayRuleListID" - ) - overstay_time_threshold: int = Field(None, alias="OverstayTimeThreshold") - overstay_power_threshold: RationalNumber = Field( - None, alias="OverstayPowerThreshold" + time_threshold: int = Field( + None, ge=0, le=UINT_32_MAX, alias="OverstayTimeThreshold" ) - overstay_rule: List[OverstayRule] = Field(..., max_items=5, alias="OverstayRule") + power_threshold: RationalNumber = Field(None, alias="OverstayPowerThreshold") + rules: List[OverstayRule] = Field(..., max_items=5, alias="OverstayRule") class AdditionalService(BaseModel): """See section 8.3.5.3.58 in ISO 15118-20""" - service_name: str = Field(..., max_length=80, alias="ServiceName") + service_name: Name = Field(..., alias="ServiceName") service_fee: RationalNumber = Field(..., alias="ServiceFee") class AdditionalServiceList(BaseModel): """See section 8.3.5.3.57 in ISO 15118-20""" - additional_service: List[AdditionalService] = Field( + additional_services: List[AdditionalService] = Field( ..., max_items=5, alias="AdditionalService" ) @@ -657,7 +691,7 @@ class AbsolutePriceSchedule(PriceSchedule): tax_rules: TaxRuleList = Field(None, alias="TaxRules") price_rule_stacks: PriceRuleStackList = Field(..., alias="PriceRuleStacks") overstay_rules: OverstayRuleList = Field(None, alias="OverstayRules") - additional_selected_services: AdditionalServiceList = Field( + additional_services: AdditionalServiceList = Field( None, alias="AdditionalSelectedServices" ) @@ -705,6 +739,9 @@ class DischargingSchedule(BaseModel): None, alias="AbsolutePriceSchedule" ) + # TODO Need to add a root validator to check if power schedule entries are negative + # for discharging (also heck other discharging fields in other types) + @root_validator(pre=True) def either_price_levels_or_absolute_prices(cls, values): """ @@ -733,21 +770,23 @@ def either_price_levels_or_absolute_prices(cls, values): class ScheduleTuple(BaseModel): """See section 8.3.5.3.17 in ISO 15118-20""" - schedule_tuple_id: str = Field(..., max_length=255, alias="ScheduleTupleID") + schedule_tuple_id: NumericID = Field(..., alias="ScheduleTupleID") charging_schedule: ChargingSchedule = Field(..., alias="ChargingSchedule") - discharging_schedule: DischargingSchedule = Field(..., alias="DischargingSchedule") + discharging_schedule: DischargingSchedule = Field(None, alias="DischargingSchedule") class ScheduledScheduleExchangeResParams(BaseModel): """See section 8.3.5.3.16 in ISO 15118-20""" - schedule_tuple: List[ScheduleTuple] = Field(..., max_items=3, alias="ScheduleTuple") + schedule_tuples: List[ScheduleTuple] = Field( + ..., max_items=3, alias="ScheduleTuple" + ) class DynamicScheduleExchangeResParams(BaseModel): """See section 8.3.5.3.15 in ISO 15118-20""" - departure_time: int = Field(None, alias="DepartureTime") + departure_time: int = Field(None, ge=0, le=UINT_32_MAX, alias="DepartureTime") # XSD type byte with value range [0..100] min_soc: int = Field(None, ge=0, le=100, alias="MinimumSOC") # XSD type byte with value range [0..100] @@ -757,6 +796,29 @@ class DynamicScheduleExchangeResParams(BaseModel): None, alias="AbsolutePriceSchedule" ) + @root_validator(pre=True) + def min_soc_less_than_or_equal_to_target_soc(cls, values): + """ + The min_soc value must be smaller than target_soc ([V2G20-1640]). + + Pydantic validators are "class methods", + see https://pydantic-docs.helpmanual.io/usage/validators/ + """ + # pylint: disable=no-self-argument + # pylint: disable=no-self-use + # TODO Check if you need to also consider the field names MinimumSOC and + # TargetSOC when decoding from EXI (if yes, check other classes as well) + # for the same issue) + # TODO Also check other classes that contain min_soc and target_soc + min_soc, target_soc = values.get("min_soc"), values.get("target_soc") + if (min_soc and target_soc) and min_soc > target_soc: + raise ValueError( + "MinimumSOC must be less than or equal to TargetSOC.\n" + f"MinimumSOC: {min_soc}, TargetSOC: {target_soc}" + ) + + return values + @root_validator(pre=True) def either_price_levels_or_absolute_prices(cls, values): """ @@ -786,14 +848,44 @@ class ScheduleExchangeRes(V2GResponse): """See section 8.3.4.3.7.3 in ISO 15118-20""" evse_processing: Processing = Field(..., alias="EVSEProcessing") - scheduled_se_res: ScheduledScheduleExchangeResParams = Field( - ..., alias="Scheduled_SEResControlMode" + scheduled_params: ScheduledScheduleExchangeResParams = Field( + None, alias="Scheduled_SEResControlMode" ) - dynamic_se_res: DynamicScheduleExchangeResParams = Field( - ..., alias="Dynamic_SEResControlMode" + dynamic_params: DynamicScheduleExchangeResParams = Field( + None, alias="Dynamic_SEResControlMode" ) go_to_pause: bool = Field(None, alias="GoToPause") + @root_validator(pre=True) + def either_scheduled_or_dynamic(cls, values): + """ + Either scheduled_params or dynamic_params must be set, depending on + whether the charging process is governed by charging schedules or + dynamic charging settings from the SECC. + + Pydantic validators are "class methods", + see https://pydantic-docs.helpmanual.io/usage/validators/ + """ + # pylint: disable=no-self-argument + # pylint: disable=no-self-use + evse_processing = values.get("evse_processing") + if evse_processing == Processing.ONGOING: + return values + + # Check if either the dynamic or scheduled parameters are set, but only in case + # evse_processing is set to FINISHED + if one_field_must_be_set( + [ + "scheduled_params", + "Scheduled_SEResControlMode", + "dynamic_params", + "Dynamic_SEResControlMode", + ], + values, + True, + ): + return values + class EVPowerProfileEntryList(BaseModel): """See section 8.3.5.3.10 in ISO 15118-20""" @@ -813,9 +905,7 @@ class PowerToleranceAcceptance(str, Enum): class ScheduledEVPowerProfile(BaseModel): """See section 8.3.5.3.12 in ISO 15118-20""" - selected_schedule_tuple_id: int = Field( - ..., ge=1, le=UINT_32_MAX, alias="SelectedScheduleTupleID" - ) + selected_schedule_tuple_id: NumericID = Field(..., alias="SelectedScheduleTupleID") power_tolerance_acceptance: PowerToleranceAcceptance = Field( ..., alias="PowerToleranceAcceptance" ) @@ -898,9 +988,7 @@ class PowerDeliveryRes(V2GResponse): class ScheduledSignedMeterData(BaseModel): """See section 8.3.5.3.38 in ISO 15118-20""" - selected_schedule_tuple_id: int = Field( - ..., ge=1, le=UINT_32_MAX, alias="SelectedScheduleTupleID" - ) + selected_schedule_tuple_id: NumericID = Field(..., alias="SelectedScheduleTupleID") class DynamicSignedMeterData(BaseModel): @@ -959,8 +1047,7 @@ def check_sessionid_is_hexbinary(cls, value): # pylint: disable=no-self-argument # pylint: disable=no-self-use try: - # convert value to int, assuming base 16 - int(value, 16) + test = int(value, 16) return value except ValueError as exc: raise ValueError( @@ -982,18 +1069,18 @@ class MeteringConfirmationRes(V2GResponse): class ChargingSession(str, Enum): """See section 8.3.4.3.10.2 in ISO 15118-20""" - pause = "Pause" - terminate = "Terminate" - service_renegotiation = "ServiceRenegotiation" + PAUSE = "Pause" + TERMINATE = "Terminate" + SERVICE_RENEGOTIATION = "ServiceRenegotiation" class SessionStopReq(V2GRequest): """See section 8.3.4.3.10.2 in ISO 15118-20""" charging_session: ChargingSession = Field(..., alias="ChargingSession") - ev_termination_code: str = Field(..., max_length=80, alias="EVTerminationCode") + ev_termination_code: Name = Field(None, alias="EVTerminationCode") ev_termination_explanation: str = Field( - ..., max_length=160, alias="EVTerminationExplanation" + None, max_length=160, alias="EVTerminationExplanation" ) @@ -1007,14 +1094,16 @@ class CertificateInstallationReq(V2GRequest): oem_prov_cert_chain: SignedCertificateChain = Field( ..., alias="OEMProvisioningCertificateChain" ) - list_of_root_cert_ids: RootCertificateID = Field( + root_cert_id_list: RootCertificateIDList = 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" + ..., ge=0, le=UINT_16_MAX, alias="MaximumContractCertificateChains" + ) + prioritized_emaids: List[EMAID] = Field( + None, max_items=8, alias="PrioritizedEMAIDs" ) - prioritized_emaids: EMAIDList = Field(None, alias="PrioritizedEMAIDs") class SignedInstallationData(BaseModel): @@ -1143,3 +1232,53 @@ class VehicleCheckOutRes(V2GResponse): """See section 8.3.4.8.1.3.2 in ISO 15118-20""" evse_check_out_status: EVSECheckOutStatus = Field(..., alias="EVSECheckOutStatus") + + +# ============================================================================ +# | HELPFUL CUSTOM CLASSES FOR A COMMUNICATION SESSION | +# ============================================================================ + + +@dataclass +class OfferedService: + """ + This class puts all service-related information into one place. ISO 15118-20 + messages and data types scatter information about service ID, typeo of service + (energy or value-added service) parameter sets, and whether a service is free. + This custom class provides easier access to all this information, which comes in + handy throughout the various states. + """ + + service: ServiceV20 + # If it's not an energy transfer service, then it's a value-added service (VAS) + is_energy_service: bool + is_free: bool + parameter_sets: List[ParameterSet] + + +@dataclass +class SelectedEnergyService: + """ + This class puts all necessary information about the energy service, which the EVCC + selects for a charging session, in one place. A SelectedService instance (datatype + used in ISO 15118-20) only contains a ServiceID and a ParameterSetID, but not the + actual parameter sets, for which we'd have to look elsewhere and loop through a + list of offered parameter sets. The parameter sets describe important service + details, which we need throughout the state machine. + """ + + service: ServiceV20 + is_free: bool + parameter_set: ParameterSet + + +@dataclass +class SelectedVAS: + """ + Similar to the custom class SelectedEnergyService, but for the value-added services + (VAS), which the EVCC selects for a charging session. + """ + + service: ServiceV20 + is_free: bool + parameter_set: ParameterSet diff --git a/iso15118/shared/messages/iso15118_20/common_types.py b/iso15118/shared/messages/iso15118_20/common_types.py index 509ac7f3..a8ece0e2 100644 --- a/iso15118/shared/messages/iso15118_20/common_types.py +++ b/iso15118/shared/messages/iso15118_20/common_types.py @@ -14,17 +14,25 @@ from enum import Enum from typing import List -from pydantic import Field, conbytes, constr, validator +from pydantic import Field, validator, conint, constr from iso15118.shared.messages import BaseModel -from iso15118.shared.messages.enums import UINT_32_MAX +from iso15118.shared.messages.enums import ( + UINT_32_MAX, + INT_8_MIN, + INT_8_MAX, + INT_16_MIN, + INT_16_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) + +# Check V2G_CI_CommonTypes.xsd (numericIDType) +NumericID = conint(ge=1, le=UINT_32_MAX) +# Check V2G_CI_CommonTypes.xsd (nameType) +Name = constr(max_length=80) +# Check V2G_CI_CommonTypes.xsd (descriptionType) +Description = constr(max_length=160) class MessageHeader(BaseModel): @@ -47,8 +55,7 @@ def check_sessionid_is_hexbinary(cls, value): # pylint: disable=no-self-argument # pylint: disable=no-self-use try: - # convert value to int, assuming base 16 - int(value, 16) + test = int(value, 16) return value except ValueError as exc: raise ValueError( @@ -141,9 +148,9 @@ class RationalNumber(BaseModel): """See section 8.3.5.3.8 in ISO 15118-20""" # XSD type byte with value range [-128..127] - exponent: int = Field(..., ge=-128, le=127, alias="Exponent") + exponent: int = Field(..., ge=INT_8_MIN, le=INT_8_MAX, alias="Exponent") # XSD type short (16 bit integer) with value range [-32768..32767] - value: int = Field(..., ge=-32768, le=32767, alias="Value") + value: int = Field(..., ge=INT_16_MIN, le=INT_16_MAX, alias="Value") class EVSENotification(str, Enum): @@ -284,7 +291,7 @@ class DynamicChargeLoopReq(BaseModel, ABC): ev_target_energy_request: RationalNumber = Field(..., alias="EVTargetEnergyRequest") ev_max_energy_request: RationalNumber = Field(..., alias="EVMaximumEnergyRequest") ev_min_energy_request: RationalNumber = Field(..., alias="EVMinimumEnergyRequest") - departure_time: int = Field(None, alias="DepartureTime") + departure_time: int = Field(None, ge=0, le=UINT_32_MAX, alias="DepartureTime") class DynamicChargeLoopRes(BaseModel): @@ -294,7 +301,7 @@ class DynamicChargeLoopRes(BaseModel): See page 465 of Annex A in ISO 15118-20 """ - departure_time: int = Field(None, alias="DepartureTime") + departure_time: int = Field(None, ge=0, le=UINT_32_MAX, alias="DepartureTime") # XSD type byte with value range [0..100] min_soc: int = Field(None, ge=0, le=100, alias="MinimumSOC") # XSD type byte with value range [0..100] @@ -323,9 +330,9 @@ class Processing(str, Enum): WAITING_FOR_CUSTOMER = "Ongoing_WaitingForCustomerInteraction" -class RootCertificateID(BaseModel): +class RootCertificateIDList(BaseModel): """See section 8.3.5.3.27 in ISO 15118-20""" - x509_issuer_serial: List[X509IssuerSerial] = Field( + root_cert_ids: List[X509IssuerSerial] = Field( ..., max_items=20, alias="RootCertificateID" ) diff --git a/iso15118/shared/messages/iso15118_20/dc.py b/iso15118/shared/messages/iso15118_20/dc.py index c3fab5f0..e1794089 100644 --- a/iso15118/shared/messages/iso15118_20/dc.py +++ b/iso15118/shared/messages/iso15118_20/dc.py @@ -10,3 +10,484 @@ (or class) that matches the definitions in the XSD schema, including the XSD element names by using the 'alias' attribute. """ + +from pydantic import Field, root_validator + +from iso15118.shared.messages import BaseModel +from iso15118.shared.messages.iso15118_20.common_types import ( + ChargeLoopReq, + ChargeLoopRes, + ChargeParameterDiscoveryReq, + ChargeParameterDiscoveryRes, + DynamicChargeLoopReq, + DynamicChargeLoopRes, + RationalNumber, + ScheduledChargeLoopReqParams, + ScheduledChargeLoopResParams, +) +from iso15118.shared.validators import one_field_must_be_set + + +class DCChargeParameterDiscoveryReqParams(BaseModel): + """See section 8.3.5.4.1 in ISO 15118-20""" + + ev_max_charge_power: RationalNumber = Field(..., alias="EVMaximumChargePower") + ev_min_charge_power: RationalNumber = Field(..., alias="EVMinimumChargePower") + ev_max_charge_current: RationalNumber = Field(..., alias="EVMaximumChargeCurrent") + ev_min_charge_current: RationalNumber = Field(..., alias="EVMinimumChargeCurrent") + ev_max_voltage: RationalNumber = Field(..., alias="EVMaximumVoltage") + ev_min_voltage: RationalNumber = Field(..., alias="EVMinimumVoltage") + target_soc: int = Field(None, ge=0, le=100, alias="TargetSOC") + + +class DCChargeParameterDiscoveryResParams(BaseModel): + """See section 8.3.5.4.2 in ISO 15118-20""" + + evse_max_charge_power: RationalNumber = Field(..., alias="EVSEMaximumChargePower") + evse_min_charge_power: RationalNumber = Field(..., alias="EVSEMinimumChargePower") + evse_max_charge_current: RationalNumber = Field( + ..., alias="EVSEMaximumChargeCurrent" + ) + evse_min_charge_current: RationalNumber = Field( + ..., alias="EVSEMinimumChargeCurrent" + ) + evse_max_voltage: RationalNumber = Field(..., alias="EVSEMaximumVoltage") + evse_min_voltage: RationalNumber = Field(..., alias="EVSEMinimumVoltage") + evse_power_ramp_limit: RationalNumber = Field(None, alias="EVSEPowerRampLimitation") + + +class BPTDCChargeParameterDiscoveryReqParams(DCChargeParameterDiscoveryReqParams): + """ + See section 8.3.5.4.7.1 in ISO 15118-20 + BPT = Bidirectional Power Transfer + """ + + ev_max_discharge_power: RationalNumber = Field(..., alias="EVMaximumDischargePower") + ev_min_discharge_power: RationalNumber = Field(..., alias="EVMinimumDischargePower") + ev_max_discharge_current: RationalNumber = Field( + ..., alias="EVMaximumDischargeCurrent" + ) + ev_min_discharge_current: RationalNumber = Field( + ..., alias="EVMinimumDischargeCurrent" + ) + + +class BPTDCChargeParameterDiscoveryResParams(DCChargeParameterDiscoveryResParams): + """ + See section 8.3.5.4.7.2 in ISO 15118-20 + BPT = Bidirectional Power Transfer + """ + + evse_max_discharge_power: RationalNumber = Field( + ..., alias="EVSEMaximumDischargePower" + ) + evse_min_discharge_power: RationalNumber = Field( + ..., alias="EVSEMinimumDischargePower" + ) + evse_max_discharge_current: RationalNumber = Field( + ..., alias="EVSEMaximumDischargeCurrent" + ) + evse_min_discharge_current: RationalNumber = Field( + ..., alias="EVSEMinimumDischargeCurrent" + ) + + +class ScheduledDCChargeLoopReqParams(ScheduledChargeLoopReqParams): + """See section 8.3.5.4.4 in ISO 15118-20""" + + ev_max_charge_power: RationalNumber = Field(None, alias="EVMaximumChargePower") + ev_max_charge_power_l2: RationalNumber = Field( + None, alias="EVMaximumChargePower_L2" + ) + ev_max_charge_power_l3: RationalNumber = Field( + None, alias="EVMaximumChargePower_L3" + ) + ev_min_charge_power: RationalNumber = Field(None, alias="EVMinimumChargePower") + ev_min_charge_power_l2: RationalNumber = Field( + None, alias="EVMinimumChargePower_L2" + ) + ev_min_charge_power_l3: RationalNumber = Field( + None, alias="EVMinimumChargePower_L3" + ) + ev_present_active_power: RationalNumber = Field(..., alias="EVPresentActivePower") + ev_present_active_power_l2: RationalNumber = Field( + None, alias="EVPresentActivePower_L2" + ) + ev_present_active_power_l3: RationalNumber = Field( + None, alias="EVPresentActivePower_L3" + ) + ev_present_reactive_power: RationalNumber = Field( + None, alias="EVPresentReactivePower" + ) + ev_present_reactive_power_l2: RationalNumber = Field( + None, alias="EVPresentReactivePower_L2" + ) + ev_present_reactive_power_l3: RationalNumber = Field( + None, alias="EVPresentReactivePower_L3" + ) + + +class ScheduledDCChargeLoopResParams(ScheduledChargeLoopResParams): + """See section 8.3.5.4.6 in ISO 15118-20""" + + evse_target_active_power: RationalNumber = Field( + None, alias="EVSETargetActivePower" + ) + evse_target_active_power_l2: RationalNumber = Field( + None, alias="EVSETargetActivePower_L2" + ) + evse_target_active_power_l3: RationalNumber = Field( + None, alias="EVSETargetActivePower_L3" + ) + evse_target_reactive_power: RationalNumber = Field( + None, alias="EVSETargetReactivePower" + ) + evse_target_reactive_power_l2: RationalNumber = Field( + None, alias="EVSETargetReactivePower_L2" + ) + evse_target_reactive_power_l3: RationalNumber = Field( + None, alias="EVSETargetReactivePower_L3" + ) + evse_present_active_power: RationalNumber = Field( + None, alias="EVSEPresentActivePower" + ) + evse_present_active_power_l2: RationalNumber = Field( + None, alias="EVSEPresentActivePower_L2" + ) + evse_present_active_power_l3: RationalNumber = Field( + None, alias="EVSEPresentActivePower_L3" + ) + + +class BPTScheduledDCChargeLoopReqParams(ScheduledDCChargeLoopReqParams): + """See section 8.3.5.4.7.4 in ISO 15118-20""" + + ev_max_discharge_power: RationalNumber = Field( + None, alias="EVMaximumDischargePower" + ) + ev_max_discharge_power_l2: RationalNumber = Field( + None, alias="EVMaximumDischargePower_L2" + ) + ev_max_discharge_power_l3: RationalNumber = Field( + None, alias="EVMaximumDischargePower_L3" + ) + ev_min_discharge_power: RationalNumber = Field( + None, alias="EVMinimumDischargePower" + ) + ev_min_discharge_power_l2: RationalNumber = Field( + None, alias="EVMinimumDischargePower_L2" + ) + ev_min_discharge_power_l3: RationalNumber = Field( + None, alias="EVMinimumDischargePower_L3" + ) + + +class BPTScheduledDCChargeLoopResParams(ScheduledDCChargeLoopResParams): + """See section 8.3.5.4.7.6 in ISO 15118-20""" + + +class DynamicDCChargeLoopReq(DynamicChargeLoopReq): + """See section 8.3.5.4.3 in ISO 15118-20""" + + ev_max_charge_power: RationalNumber = Field(..., alias="EVMaximumChargePower") + ev_max_charge_power_l2: RationalNumber = Field( + None, alias="EVMaximumChargePower_L2" + ) + ev_max_charge_power_l3: RationalNumber = Field( + None, alias="EVMaximumChargePower_l2" + ) + ev_min_charge_power: RationalNumber = Field(..., alias="EVMinimumChargePower") + ev_min_charge_power_l2: RationalNumber = Field( + None, alias="EVMinimumChargePower_L2" + ) + ev_min_charge_power_l3: RationalNumber = Field( + None, alias="EVMinimumChargePower_L3" + ) + ev_present_active_power: RationalNumber = Field(..., alias="EVPresentActivePower") + ev_present_active_power_l2: RationalNumber = Field( + None, alias="EVPresentActivePower_L2" + ) + ev_present_active_power_l3: RationalNumber = Field( + None, alias="EVPresentActivePower_L3" + ) + ev_present_reactive_power: RationalNumber = Field( + ..., alias="EVPresentReactivePower" + ) + ev_present_reactive_power_l2: RationalNumber = Field( + None, alias="EVPresentReactivePower_L2" + ) + ev_present_reactive_power_l3: RationalNumber = Field( + None, alias="EVPresentReactivePower_L3" + ) + + +class DynamicDCChargeLoopRes(DynamicChargeLoopRes): + """See section 8.3.5.4.5 in ISO 15118-20""" + + evse_target_active_power: RationalNumber = Field(..., alias="EVSETargetActivePower") + evse_target_active_power_l2: RationalNumber = Field( + None, alias="EVSETargetActivePower_L2" + ) + evse_target_active_power_l3: RationalNumber = Field( + None, alias="EVSETargetActivePower_L3" + ) + evse_target_reactive_power: RationalNumber = Field( + None, alias="EVSETargetReactivePower" + ) + evse_target_reactive_power_l2: RationalNumber = Field( + None, alias="EVSETargetReactivePower_L2" + ) + evse_target_reactive_power_l3: RationalNumber = Field( + None, alias="EVSETargetReactivePower_L3" + ) + evse_present_active_power: RationalNumber = Field( + None, alias="EVSEPresentActivePower" + ) + evse_present_active_power_l2: RationalNumber = Field( + None, alias="EVSEPresentActivePower_L2" + ) + evse_present_active_power_l3: RationalNumber = Field( + None, alias="EVSEPresentActivePower_L3" + ) + + +class BPTDynamicDCChargeLoopReq(DynamicDCChargeLoopReq): + """See section 8.3.5.4.7.3 in ISO 15118-20""" + + ev_max_discharge_power: RationalNumber = Field(..., alias="EVMaximumDischargePower") + ev_max_discharge_power_l2: RationalNumber = Field( + None, alias="EVMaximumDischargePower_L2" + ) + ev_max_discharge_power_l3: RationalNumber = Field( + None, alias="EVMaximumDischargePower_L3" + ) + ev_min_discharge_power: RationalNumber = Field(..., alias="EVMinimumDischargePower") + ev_min_discharge_power_l2: RationalNumber = Field( + None, alias="EVMinimumDischargePower_L2" + ) + ev_min_discharge_power_l3: RationalNumber = Field( + None, alias="EVMinimumDischargePower_L3" + ) + ev_max_v2x_energy_request: RationalNumber = Field( + None, alias="EVMaximumV2XEnergyRequest" + ) + ev_min_v2x_energy_request: RationalNumber = Field( + None, alias="EVMinimumV2XEnergyRequest" + ) + + +class BPTDynamicDCChargeLoopRes(DynamicDCChargeLoopRes): + """See section 8.3.5.4.7.5 in ISO 15118-20""" + + +class DCChargeParameterDiscoveryReq(ChargeParameterDiscoveryReq): + """See section 8.3.4.4.2.2 in ISO 15118-20""" + + dc_params: DCChargeParameterDiscoveryReqParams = Field( + None, alias="DC_CPDReqEnergyTransferMode" + ) + bpt_dc_params: BPTDCChargeParameterDiscoveryReqParams = Field( + None, alias="BPT_DC_CPDReqEnergyTransferMode" + ) + + @root_validator(pre=True) + def either_dc_or_dc_bpt_params(cls, values): + """ + Either dc_params or bpt_dc_params must be set, depending on whether + unidirectional or bidirectional power transfer was chosen. + + Pydantic validators are "class methods", + see https://pydantic-docs.helpmanual.io/usage/validators/ + """ + # pylint: disable=no-self-argument + # pylint: disable=no-self-use + if one_field_must_be_set( + [ + "dc_params", + "DC_CPDReqEnergyTransferMode", + "bpt_dc_params", + "BPT_DC_CPDReqEnergyTransferMode", + ], + values, + True, + ): + return values + + def __str__(self): + # The XSD-conform name + return "DC_ChargeParameterDiscoveryReq" + + +class DCChargeParameterDiscoveryRes(ChargeParameterDiscoveryRes): + """See section 8.3.4.4.2.3 in ISO 15118-20""" + + dc_params: DCChargeParameterDiscoveryResParams = Field( + None, alias="DC_CPDResEnergyTransferMode" + ) + bpt_dc_params: BPTDCChargeParameterDiscoveryResParams = Field( + None, alias="BPT_DC_CPDResEnergyTransferMode" + ) + + @root_validator(pre=True) + def either_dc_or_bpt_dc_params(cls, values): + """ + Either dc_params or bpt_dc_params must be set, depending on whether + unidirectional or bidirectional power transfer was chosen. + + Pydantic validators are "class methods", + see https://pydantic-docs.helpmanual.io/usage/validators/ + """ + # pylint: disable=no-self-argument + # pylint: disable=no-self-use + if one_field_must_be_set( + [ + "dc_params", + "DC_CPDResEnergyTransferMode", + "bpt_dc_params", + "BPT_DC_CPDResEnergyTransferMode", + ], + values, + True, + ): + return values + + def __str__(self): + # The XSD-conform name + return "DC_ChargeParameterDiscoveryRes" + + +class DCChargeLoopReq(ChargeLoopReq): + """See section 8.3.4.4.3.2 in ISO 15118-20""" + + scheduled_dc_charge_loop_req: ScheduledDCChargeLoopReqParams = Field( + None, alias="Scheduled_DC_CLReqControlMode" + ) + dynamic_dc_charge_loop_req: DynamicDCChargeLoopReq = Field( + None, alias="Dynamic_DC_CLReqControlMode" + ) + bpt_scheduled_dc_charge_loop_req: BPTScheduledDCChargeLoopReqParams = Field( + None, alias="BPT_Scheduled_DC_CLReqControlMode" + ) + bpt_dynamic_dc_charge_loop_req: BPTDynamicDCChargeLoopReq = Field( + None, alias="BPT_Dynamic_DC_CLReqControlMode" + ) + + @root_validator(pre=True) + def either_scheduled_or_dynamic_bpt(cls, values): + """ + Either scheduled_dc_charge_loop_req or scheduled_dc_charge_loop_req or + bpt_scheduled_dc_charge_loop_req or bpt_dynamic_dc_charge_loop_req + must be set, depending on whether unidirectional or bidirectional power + transfer and whether scheduled or dynamic mode was chosen. + + Pydantic validators are "class methods", + see https://pydantic-docs.helpmanual.io/usage/validators/ + """ + # pylint: disable=no-self-argument + # pylint: disable=no-self-use + if one_field_must_be_set( + [ + "scheduled_dc_charge_loop_req", + "Scheduled_DC_CLReqControlMode", + "dynamic_dc_charge_loop_req", + "Dynamic_DC_CLReqControlMode", + "bpt_scheduled_dc_charge_loop_req", + "BPT_Scheduled_DC_CLReqControlMode", + "bpt_dynamic_dc_charge_loop_req", + "BPT_Dynamic_DC_CLReqControlMode", + ], + values, + True, + ): + return values + + def __str__(self): + # The XSD-conform name + return "DC_ChargeLoopReq" + + +class DCChargeLoopRes(ChargeLoopRes): + """See section 8.3.4.4.3.3 in ISO 15118-20""" + + evse_target_frequency: RationalNumber = Field(None, alias="EVSETargetFrequency") + scheduled_dc_charge_loop_res: ScheduledDCChargeLoopResParams = Field( + None, alias="Scheduled_DC_CLResControlMode" + ) + dynamic_dc_charge_loop_res: DynamicDCChargeLoopRes = Field( + None, alias="Dynamic_DC_CLResControlMode" + ) + bpt_scheduled_dc_charge_loop_res: BPTScheduledDCChargeLoopResParams = Field( + None, alias="BPT_Scheduled_DC_CLResControlMode" + ) + bpt_dynamic_dc_charge_loop_res: BPTDynamicDCChargeLoopRes = Field( + None, alias="BPT_Dynamic_DC_CLResControlMode" + ) + + @root_validator(pre=True) + def either_scheduled_or_dynamic_bpt(cls, values): + """ + Either scheduled_dc_charge_loop_res or scheduled_dc_charge_loop_res or + bpt_scheduled_dc_charge_loop_res or bpt_dynamic_dc_charge_loop_res + must be set, depending on whether unidirectional or bidirectional power + transfer and whether scheduled or dynamic mode was chosen. + + Pydantic validators are "class methods", + see https://pydantic-docs.helpmanual.io/usage/validators/ + """ + # pylint: disable=no-self-argument + # pylint: disable=no-self-use + if one_field_must_be_set( + [ + "scheduled_dc_charge_loop_res", + "Scheduled_DC_CLResControlMode", + "dynamic_dc_charge_loop_res", + "Dynamic_DC_CLResControlMode", + "bpt_scheduled_dc_charge_loop_res", + "BPT_Scheduled_DC_CLResControlMode", + "bpt_dynamic_dc_charge_loop_res", + "BPT_Dynamic_DC_CLResControlMode", + ], + values, + True, + ): + return values + + def __str__(self): + # The XSD-conform name + return "DC_ChargeLoopRes" + + +class DCCableCheckReq(BaseModel): + def __str__(self): + # The XSD-conform name + return "DC_CableCheckReq" + + +class DCCableCheckRes(BaseModel): + def __str__(self): + # The XSD-conform name + return "DC_CableCheckRes" + + +class DCPreChargeReq(BaseModel): + def __str__(self): + # The XSD-conform name + return "DC_PreChargeReq" + + +class DCPreChargeRes(BaseModel): + def __str__(self): + # The XSD-conform name + return "DC_PreChargeRes" + + +class DCWeldingDetectionReq(BaseModel): + def __str__(self): + # The XSD-conform name + return "DC_WeldingDetectionReq" + + +class DCWeldingDetectionRes(BaseModel): + def __str__(self): + # The XSD-conform name + return "DC_WeldingDetectionRes" diff --git a/iso15118/shared/messages/sdp.py b/iso15118/shared/messages/sdp.py index 84eff1e4..258f1d09 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 """ diff --git a/iso15118/shared/settings.py b/iso15118/shared/settings.py index e9d0cfd4..b1af68f1 100644 --- a/iso15118/shared/settings.py +++ b/iso15118/shared/settings.py @@ -2,18 +2,17 @@ import environs -ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) -JAR_FILE_PATH = ROOT_DIR + "/EXICodec.jar" +SHARED_CWD = os.path.dirname(os.path.abspath(__file__)) +JAR_FILE_PATH = SHARED_CWD + "/EXICodec.jar" WORK_DIR = os.getcwd() -SHARED_CWD = WORK_DIR + "/iso15118/shared/" ENV_PATH = WORK_DIR + "/.env" env = environs.Env(eager=False) env.read_env(path=ENV_PATH) # read .env file, if it exists -PKI_PATH = env.str("PKI_PATH", default=SHARED_CWD + "pki/") +PKI_PATH = env.str("PKI_PATH", default=SHARED_CWD + "/pki/") MESSAGE_LOG_JSON = env.bool("MESSAGE_LOG_JSON", default=True) MESSAGE_LOG_EXI = env.bool("MESSAGE_LOG_EXI", default=False) diff --git a/iso15118/shared/validators.py b/iso15118/shared/validators.py index e3229492..a835fd4f 100644 --- a/iso15118/shared/validators.py +++ b/iso15118/shared/validators.py @@ -8,7 +8,10 @@ def validate_bytes_value_range( - var_name: str, var_bytes: bytes, min_val: int, max_val: int + var_name: str, + var_bytes: bytes, + min_val: int, + max_val: int ) -> bool: """ Checks whether the provided integer value represented with the bytes object @@ -57,8 +60,10 @@ def one_field_must_be_set( """ set_fields: List = [] for field_name in field_options: - field = values.get(field_name) - if field: + field = values.get(f"{field_name}") + # Important to not check for "if field" instead of "if field is not None" to + # avoid situations in which field evaluates to 0 (which equals to False) + if field is not None: set_fields.append(field) if mutually_exclusive and len(set_fields) != 1: