Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support pause/wakeup in 15118-2 #198

Merged
merged 20 commits into from
Jun 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions iso15118/evcc/states/iso15118_20_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Namespace,
ParameterName,
ServiceV20,
SessionStopAction,
)
from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2
from iso15118.shared.messages.iso15118_20.ac import (
Expand Down Expand Up @@ -1125,11 +1126,16 @@ async def process_message(
if not msg:
return

session_stop_reason = self.comm_session.charging_session_stop_v20.lower()
if session_stop_reason == "pause":
session_stop_action = SessionStopAction.PAUSE
else:
session_stop_action = SessionStopAction.TERMINATE
self.comm_session.stop_reason = StopNotification(
True,
f"Communication session "
f"{self.comm_session.charging_session_stop_v20.lower()}d",
f"Communication session " f"{session_stop_reason}d",
self.comm_session.writer.get_extra_info("peername"),
session_stop_action,
)

if (
Expand Down
25 changes: 20 additions & 5 deletions iso15118/secc/comm_session_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
from asyncio.streams import StreamReader, StreamWriter
from typing import Dict, List, Optional, Tuple, Union

from iso15118.secc.controller.interface import EVSEControllerInterface, ServiceStatus
from iso15118.secc.controller.interface import (
EVSEControllerInterface,
EVSessionContext15118,
ServiceStatus,
)
from iso15118.secc.failed_responses import (
init_failed_responses_din_spec_70121,
init_failed_responses_iso_v2,
Expand All @@ -34,6 +38,7 @@
ISOV2PayloadTypes,
ISOV20PayloadTypes,
Protocol,
SessionStopAction,
)
from iso15118.shared.messages.iso15118_2.datatypes import (
CertificateChain as CertificateChainV2,
Expand Down Expand Up @@ -125,6 +130,7 @@ def __init__(
# CurrentDemandRes. The SECC must send a copy in the MeteringReceiptReq
# TODO Add support for ISO 15118-20 MeterInfo
self.sent_meter_info: Optional[MeterInfoV2] = None
self.ev_session_context: EVSessionContext15118 = EVSessionContext15118()
self.is_tls = self._is_tls(transport)

def save_session_info(self):
Expand Down Expand Up @@ -283,7 +289,9 @@ async def get_from_rcv_queue(self, queue: asyncio.Queue):
self.comm_sessions[notification.ip_address] = (comm_session, task)
elif isinstance(notification, StopNotification):
try:
await self.end_current_session(notification.peer_ip_address)
await self.end_current_session(
notification.peer_ip_address, notification.stop_action
)
except KeyError:
pass
else:
Expand All @@ -296,11 +304,18 @@ async def get_from_rcv_queue(self, queue: asyncio.Queue):
finally:
queue.task_done()

async def end_current_session(self, peer_ip_address: str):
async def end_current_session(
self, peer_ip_address: str, session_stop_action: SessionStopAction
):
try:
await cancel_task(self.comm_sessions[peer_ip_address][1])
del self.comm_sessions[peer_ip_address]
if session_stop_action == SessionStopAction.TERMINATE:
del self.comm_sessions[peer_ip_address]
else:
logger.debug(
f"Preserved session state: {self.comm_sessions[peer_ip_address][0].ev_session_context}" # noqa
)
await cancel_task(self.tcp_server_handler)
await cancel_task(self.comm_sessions[peer_ip_address][1])
except Exception as e:
logger.warning(f"Unexpected error ending current session: {e}")

Expand Down
18 changes: 18 additions & 0 deletions iso15118/secc/controller/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
SAScheduleTupleEntry as SAScheduleTupleEntryDINSPEC,
)
from iso15118.shared.messages.enums import (
AuthEnum,
AuthorizationStatus,
AuthorizationTokenType,
ControlMode,
Expand All @@ -41,6 +42,7 @@
from iso15118.shared.messages.iso15118_2.datatypes import (
ACEVSEChargeParameter,
ACEVSEStatus,
ChargeService,
)
from iso15118.shared.messages.iso15118_2.datatypes import MeterInfo as MeterInfoV2
from iso15118.shared.messages.iso15118_2.datatypes import SAScheduleTuple
Expand Down Expand Up @@ -132,6 +134,22 @@ class EVChargeParamsLimits:
ev_energy_request: Optional[PVEVEnergyRequest] = None


@dataclass
class EVSessionContext15118:
# EVSessionContext15118 holds information required to resume a paused session.
# [V2G2-741] - In a resumed session, the following are reused:
# 1. SessionID (SessionSetup)
# 2. PaymentOption that was previously selected (ServiceDiscoveryRes)
# 3. ChargeService (ServiceDiscoveryRes)
# 4. SAScheduleTuple (ChargeParameterDiscoveryRes) -
# Previously selected id must remain the same.
# However, the entries could reflect the elapsed time
session_id: Optional[str] = None
auth_options: Optional[List[AuthEnum]] = None
charge_service: Optional[ChargeService] = None
sa_schedule_tuple_id: Optional[int] = None


class EVSEControllerInterface(ABC):
def __init__(self):
self.ev_data_context = EVDataContext()
Expand Down
11 changes: 7 additions & 4 deletions iso15118/secc/states/iso15118_20_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
ParameterName,
Protocol,
ServiceV20,
SessionStopAction,
)
from iso15118.shared.messages.iso15118_2.msgdef import V2GMessage as V2GMessageV2
from iso15118.shared.messages.iso15118_20.ac import (
Expand Down Expand Up @@ -1138,11 +1139,11 @@ async def process_message(
and await evse_controller.service_renegotiation_supported()
):
next_state = ServiceDiscoveryReq
stopped = "paused"
session_stop_state = SessionStopAction.PAUSE
elif session_stop_req.charging_session == ChargingSession.TERMINATE:
stopped = "terminated"
session_stop_state = SessionStopAction.TERMINATE
else:
stopped = "paused"
session_stop_state = SessionStopAction.PAUSE

termination_info = ""
if (
Expand All @@ -1157,8 +1158,10 @@ async def process_message(

self.comm_session.stop_reason = StopNotification(
True,
f"Communication session {stopped}. EV Info: {termination_info}",
f"Communication session {session_stop_state.value}d. "
f"EV Info: {termination_info}",
self.comm_session.writer.get_extra_info("peername"),
session_stop_state,
)

session_stop_res = SessionStopRes(
Expand Down
95 changes: 77 additions & 18 deletions iso15118/secc/states/iso15118_2_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
from typing import List, Optional, Type, Union

from iso15118.secc.comm_session_handler import SECCCommunicationSession
from iso15118.secc.controller.interface import EVChargeParamsLimits
from iso15118.secc.controller.interface import (
EVChargeParamsLimits,
EVSessionContext15118,
)
from iso15118.secc.states.secc_state import StateSECC
from iso15118.shared.exceptions import (
CertAttributeError,
Expand Down Expand Up @@ -39,6 +42,7 @@
IsolationLevel,
Namespace,
Protocol,
SessionStopAction,
)
from iso15118.shared.messages.iso15118_2.body import (
EMAID,
Expand Down Expand Up @@ -167,7 +171,12 @@ async def process_message(
if msg.header.session_id == bytes(1).hex():
# A new charging session is established
self.response_code = ResponseCode.OK_NEW_SESSION_ESTABLISHED
elif msg.header.session_id == self.comm_session.session_id:
self.comm_session.ev_session_context = EVSessionContext15118()
self.comm_session.ev_session_context.session_id = session_id
elif (
self.comm_session.ev_session_context.session_id
and msg.header.session_id == self.comm_session.ev_session_context.session_id
):
# The EV wants to resume the previously paused charging session
session_id = self.comm_session.session_id
self.response_code = ResponseCode.OK_OLD_SESSION_JOINED
Expand All @@ -179,6 +188,8 @@ async def process_message(
f"New session ID {session_id} assigned"
)
self.response_code = ResponseCode.OK_NEW_SESSION_ESTABLISHED
self.comm_session.ev_session_context = EVSessionContext15118()
self.comm_session.ev_session_context.session_id = session_id

session_setup_res = SessionSetupRes(
response_code=self.response_code,
Expand Down Expand Up @@ -286,7 +297,13 @@ async def get_services(
value is not standardized in any way
"""
auth_options: List[AuthEnum] = []
if self.comm_session.selected_auth_option:

if self.comm_session.ev_session_context.auth_options:
logger.info("AuthOptions available in context. This is a resumed session.")
# This is a resumed session.
# Return the auth option that was previously selected.
auth_options = self.comm_session.ev_session_context.auth_options
elif self.comm_session.selected_auth_option:
# In case the EVCC resumes a paused charging session, the SECC
# must only offer the auth option the EVCC selected previously
if self.comm_session.selected_auth_option == AuthEnum.EIM_V2:
Expand All @@ -311,15 +328,22 @@ async def get_services(
)
)

charge_service = ChargeService(
service_id=ServiceID.CHARGING,
service_name=ServiceName.CHARGING,
service_category=ServiceCategory.CHARGING,
free_service=self.comm_session.config.free_charging_service,
supported_energy_transfer_mode=EnergyTransferModeList(
energy_modes=energy_modes
),
)
if self.comm_session.ev_session_context.charge_service:
logger.info(
"ChargeService available in context. This is a resumed session."
)
charge_service = self.comm_session.ev_session_context.charge_service
else:
charge_service = ChargeService(
service_id=ServiceID.CHARGING,
service_name=ServiceName.CHARGING,
service_category=ServiceCategory.CHARGING,
free_service=self.comm_session.config.free_charging_service,
supported_energy_transfer_mode=EnergyTransferModeList(
energy_modes=energy_modes
),
)
self.comm_session.ev_session_context.charge_service = charge_service

service_list: List[ServiceDetails] = []
# Value-added services (VAS), like installation of contract certificates
Expand Down Expand Up @@ -590,6 +614,9 @@ async def process_message(
self.comm_session.selected_auth_option = AuthEnum(
service_selection_req.selected_auth_option.value
)
self.comm_session.ev_session_context.auth_options: List[AuthEnum] = [
self.comm_session.selected_auth_option
]

# For now, we don't really care much more about the selected
# value-added services. If the EVCC wants to do contract certificate
Expand Down Expand Up @@ -1274,6 +1301,30 @@ async def process_message(
sa_schedule_list_valid = self.validate_sa_schedule_list(
sa_schedule_list, departure_time
)

if (
sa_schedule_list_valid
and self.comm_session.ev_session_context.sa_schedule_tuple_id
):
filtered_list = list(
filter(
lambda schedule_entry: schedule_entry.sa_schedule_tuple_id
== self.comm_session.ev_session_context.sa_schedule_tuple_id,
sa_schedule_list,
)
)
if len(filtered_list) != 1:
logger.warning(
f"Resumed session. Previously selected sa_schedule_list is"
f" not present {sa_schedule_list}"
)
else:
logger.info(
f"Resumed session. SAScheduleTupleID "
f"{self.comm_session.ev_session_context.sa_schedule_tuple_id} "
f"present in context"
)

if not sa_schedule_list_valid:
# V2G2-305 : It is still acceptable if the sum of the schedule entry
# durations falls short of departure_time requested by the EVCC in
Expand Down Expand Up @@ -1537,6 +1588,7 @@ async def process_message(
# no later than 3s after measuring CP State C or D.
# Before closing the contactor, we need to check to
# ensure the CP is in state C or D

if not await self.wait_for_state_c_or_d():
logger.warning(
"C2/D2 CP state not detected after 250ms in PowerDelivery"
Expand Down Expand Up @@ -1891,16 +1943,23 @@ async def process_message(
msg = self.check_msg_v2(message, [SessionStopReq])
if not msg:
return
session_status = msg.body.session_stop_req.charging_session.lower()
self.comm_session.stop_reason = StopNotification(
True,
f"EV Requested to {session_status} the communication session",
self.comm_session.writer.get_extra_info("peername"),
)

if msg.body.session_stop_req.charging_session == ChargingSession.PAUSE:
next_state = Pause
session_stop_state = SessionStopAction.PAUSE
else:
next_state = Terminate
session_stop_state = SessionStopAction.TERMINATE
# EVSessionContext stores information for resuming a paused session.
# As Terminate is requested, clear context information.
self.comm_session.ev_session_context = None

self.comm_session.stop_reason = StopNotification(
True,
f"EV requested to {session_stop_state.value} the communication session",
self.comm_session.writer.get_extra_info("peername"),
session_stop_state,
)
self.create_next_message(
next_state,
SessionStopRes(response_code=ResponseCode.OK),
Expand Down
11 changes: 10 additions & 1 deletion iso15118/shared/notifications.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from asyncio.streams import StreamReader, StreamWriter
from typing import Tuple

from iso15118.shared.messages.enums import SessionStopAction


class Notification:
"""
Expand Down Expand Up @@ -60,7 +62,14 @@ class StopNotification(Notification):
server is serving.
"""

def __init__(self, successful: bool, reason: str, peer_ip_address: str = None):
def __init__(
self,
successful: bool,
reason: str,
peer_ip_address: str = None,
stop_action: SessionStopAction = SessionStopAction.TERMINATE,
):
self.successful = successful
self.reason = reason
self.peer_ip_address = peer_ip_address
self.stop_action = stop_action
2 changes: 1 addition & 1 deletion iso15118/shared/pki/configs/contractLeafCert.cnf
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ prompt = no
distinguished_name = ca_dn

[ca_dn]
commonName = UKSWI123456789A
commonName = UKSWI123456791A
organizationName = Switch
countryName = UK
domainComponent = MO
Expand Down
4 changes: 3 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
from iso15118.evcc.comm_session_handler import EVCCCommunicationSession
from iso15118.evcc.controller.simulator import SimEVController
from iso15118.secc.comm_session_handler import SECCCommunicationSession
from iso15118.secc.controller.interface import EVSessionContext15118
from iso15118.secc.controller.simulator import SimEVSEController
from iso15118.secc.failed_responses import init_failed_responses_iso_v2
from iso15118.shared.messages.enums import Protocol
from iso15118.shared.messages.iso15118_2.datatypes import EnergyTransferModeEnum
from iso15118.shared.notifications import StopNotification
from tests.secc.states.test_messages import get_sa_schedule_list
from tests.iso15118_2.secc.states.test_messages import get_sa_schedule_list
from tests.tools import MOCK_SESSION_ID


Expand Down Expand Up @@ -40,4 +41,5 @@ def comm_secc_session_mock():
comm_session_mock.evse_controller = SimEVSEController()
comm_session_mock.protocol = Protocol.UNKNOWN
comm_session_mock.evse_id = "UK123E1234"
comm_session_mock.ev_session_context = EVSessionContext15118()
return comm_session_mock
Loading