Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter

- CitrineOS core extracted (CSMS OCPP 2.0.1)
- OpenOCPP extracted (firmware OCPP 1.6J/2.0.1)
- ShapeShifter library installed (pip install -e)
- ShapeShifter specification extracted
- EVerest extracted

TODO updated with progress
This commit is contained in:
Eric F
2026-06-08 00:38:27 -04:00
parent 468cfeaa50
commit d398a6ced2
7326 changed files with 1177561 additions and 7 deletions

View File

@@ -0,0 +1,486 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright Pionix GmbH and Contributors to EVerest
import pytest
from datetime import datetime, timezone
import traceback
# fmt: off
import logging
from everest.testing.core_utils.controller.test_controller_interface import TestController
from ocpp.v21 import call as call21
from ocpp.v21 import call_result as call_result21
from ocpp.v21.enums import *
from ocpp.v21.datatypes import *
from ocpp.routing import on, create_route_map
from everest.testing.ocpp_utils.fixtures import *
from everest_test_utils import * # Needs to be before the datatypes below since it overrides the v21 Action enum with the v16 one
from ocpp.v21.enums import (Action, ConnectorStatusEnumType, AuthorizationStatusEnumType, EnergyTransferModeEnumType, AttributeEnumType, GetVariableStatusEnumType, NotifyEVChargingNeedsStatusEnumType, NotifyAllowedEnergyTransferStatusEnumType)
from validations import validate_status_notification_201
from everest.testing.core_utils._configuration.libocpp_configuration_helper import GenericOCPP2XConfigAdjustment
from everest.testing.ocpp_utils.charge_point_utils import wait_for_and_validate, TestUtility
# fmt: on
log = logging.getLogger("bidirectionalTest")
def validate_notify_ev_charging_needs(meta_data, msg, expected):
# Q01.FR.03
return (
msg.payload["evseId"] == expected["evseId"]
and msg.payload["chargingNeeds"]["requestedEnergyTransfer"] == expected["requestedEnergyTransfer"]
and msg.payload["chargingNeeds"]["v2xChargingParameters"]
and msg.payload["chargingNeeds"]["controlMode"] == expected["controlMode"]
)
def validate_tx_event_with_evccid(meta_data, msg, expected):
return (
msg.payload["eventType"] == expected["eventType"]
and msg.payload["idToken"]["additionalInfo"][0]["type"] == "EVCCID"
)
@pytest.mark.xdist_group(name="ISO15118")
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config("everest-config-ocpp201-sil-dc-d20-eim.yaml")
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_q01(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""
Q01
...
"""
log.info(
"##################### Q01: V2X Authorization #################"
)
id_token = IdTokenType(id_token="8BADF00D",
type="ISO14443")
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(wait_for_bootnotification=True)
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"StatusNotification",
call21.StatusNotification(
1, ConnectorStatusEnumType.available, 1, datetime.now().isoformat()
),
validate_status_notification_201,
)
iso_enabled = GetVariableDataType(component=ComponentType(name="ISO15118Ctrlr", evse=EVSEType(id=1)),
variable=VariableType(name="Enabled"),
attribute_type=AttributeEnumType.actual)
v2x_enabled = GetVariableDataType(component=ComponentType(name="V2XChargingCtrlr", evse=EVSEType(id=1)),
variable=VariableType(name="Enabled"),
attribute_type=AttributeEnumType.actual)
v2x_supported_op_modes = GetVariableDataType(component=ComponentType(name="V2XChargingCtrlr", evse=EVSEType(id=1)),
variable=VariableType(
name="SupportedOperationModes"),
attribute_type=AttributeEnumType.actual)
v2x_supported_energy_transfers = GetVariableDataType(component=ComponentType(name="V2XChargingCtrlr", evse=EVSEType(id=1)),
variable=VariableType(
name="SupportedEnergyTransferModes"),
attribute_type=AttributeEnumType.actual)
list_of_vars = [iso_enabled, v2x_enabled,
v2x_supported_op_modes, v2x_supported_energy_transfers]
r: call_result21.GetVariables = await charge_point_v21.get_variables_req(get_variable_data=list_of_vars)
for result in r.get_variable_result:
assert result['attribute_status'] == GetVariableStatusEnumType.accepted
if result['variable']['name'] == 'Enabled':
# Q01.FR.01
assert result['attribute_value'] == 'true'
elif result['variable']['name'] == 'SupportedOperationModes':
# Q01.FR.31
# TODO(mlitre) update to check for the min requirements once they are supported
# Notably we need: ChargingOnly, CentralSetpoint and CentralFrequency
assert result['attribute_value']
elif result['variable']['name'] == 'SupportedEnergyTransferModes':
# Q01.FR.32, we just check that it is not empty
assert result['attribute_value']
test_controller.plug_in_dc_iso()
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"StatusNotification",
call21.StatusNotification(
1, ConnectorStatusEnumType.occupied, 1, datetime.now().isoformat()
),
validate_status_notification_201,
)
@on(Action.authorize)
def on_authorize(**kwargs):
return call_result21.Authorize(
id_token_info=IdTokenInfoType(
status=AuthorizationStatusEnumType.accepted,
group_id_token=IdTokenType(
id_token="12345", type="Central"
),
),
allowed_energy_transfer=[EnergyTransferModeEnumType.dc_bpt]
)
setattr(charge_point_v21, "on_authorize", on_authorize)
central_system_v21.chargepoint.route_map = create_route_map(
central_system_v21.chargepoint
)
test_controller.swipe(id_token.id_token)
# Question: should we check other flows aka auth first then plug
# Checking here Q01.FR.02 for idToken is not reliable, as we start tx before we get evcc id
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"TransactionEvent",
{"eventType": "Started", "offline": False},
)
# Q01.FR.03: Check all the fields of NotifyEVChargingNeeds
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"NotifyEVChargingNeeds",
{"evseId": 1, "requestedEnergyTransfer": "DC",
"controlMode": "DynamicControl", "mobilityNeedsMode": "EVCC"},
validate_notify_ev_charging_needs
)
# Check variables after NotifyEVChargingNeeds, so that we don't have to guess when the variables have been updated
ev_available = GetVariableDataType(component=ComponentType(name="ConnectedEV", evse=EVSEType(id=1)),
variable=VariableType(name="Available"),
attribute_type=AttributeEnumType.actual)
ev_vehicle_id = GetVariableDataType(component=ComponentType(name="ConnectedEV", evse=EVSEType(id=1)),
variable=VariableType(
name="VehicleId"),
attribute_type=AttributeEnumType.actual)
ev_protocol_agreed = GetVariableDataType(component=ComponentType(name="ConnectedEV", evse=EVSEType(id=1)),
variable=VariableType(
name="ProtocolAgreed"),
attribute_type=AttributeEnumType.actual)
ev_protocol_supported_by_ev1 = GetVariableDataType(component=ComponentType(name="ConnectedEV", evse=EVSEType(id=1)),
variable=VariableType(
name="ProtocolSupportedByEV", instance="1"),
attribute_type=AttributeEnumType.actual)
list_of_vars = [ev_available, ev_vehicle_id,
ev_protocol_agreed, ev_protocol_supported_by_ev1]
r: call_result21.GetVariables = await charge_point_v21.get_variables_req(get_variable_data=list_of_vars)
# TODO(mlitre): Add check on VehicleCertificate when it is supported
# Q01.FR.36: Validate ConnectedEV variables
for result in r.get_variable_result:
assert result['attribute_status'] == GetVariableStatusEnumType.accepted
if result['variable']['name'] == 'Available':
assert result['attribute_value'] == 'true'
elif result['variable']['name'] == 'VehicleId':
assert result['attribute_value']
elif result['variable']['name'] == 'ProtocolAgreed':
assert result['attribute_value'] == 'urn:iso:std:iso:15118:-20:DC,1,0'
elif result['variable']['name'] == 'ProtocolSupportedByEV':
# TODO(mlitre): How many should we check?
assert result['attribute_value']
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"TransactionEvent",
{"eventType": "Updated", "transactionInfo": {"chargingState": "Charging"}},
)
test_controller.swipe(id_token.id_token)
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"TransactionEvent",
{"eventType": "Ended"},
validate_tx_event_with_evccid
)
@pytest.mark.xdist_group(name="ISO15118")
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config("everest-config-ocpp201-sil-dc-d20-eim.yaml")
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_rejected_q01(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""
Q01
...
"""
log.info(
"##################### Q01: V2X Authorization #################"
)
id_token = IdTokenType(id_token="8BADF00D",
type="ISO14443")
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(wait_for_bootnotification=True)
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"StatusNotification",
call21.StatusNotification(
1, ConnectorStatusEnumType.available, 1, datetime.now().isoformat()
),
validate_status_notification_201,
)
iso_enabled = GetVariableDataType(component=ComponentType(name="ISO15118Ctrlr", evse=EVSEType(id=1)),
variable=VariableType(name="Enabled"),
attribute_type=AttributeEnumType.actual)
v2x_enabled = GetVariableDataType(component=ComponentType(name="V2XChargingCtrlr", evse=EVSEType(id=1)),
variable=VariableType(name="Enabled"),
attribute_type=AttributeEnumType.actual)
v2x_supported_op_modes = GetVariableDataType(component=ComponentType(name="V2XChargingCtrlr", evse=EVSEType(id=1)),
variable=VariableType(
name="SupportedOperationModes"),
attribute_type=AttributeEnumType.actual)
v2x_supported_energy_transfers = GetVariableDataType(component=ComponentType(name="V2XChargingCtrlr", evse=EVSEType(id=1)),
variable=VariableType(
name="SupportedEnergyTransferModes"),
attribute_type=AttributeEnumType.actual)
list_of_vars = [iso_enabled, v2x_enabled,
v2x_supported_op_modes, v2x_supported_energy_transfers]
r: call_result21.GetVariables = await charge_point_v21.get_variables_req(get_variable_data=list_of_vars)
for result in r.get_variable_result:
assert result['attribute_status'] == GetVariableStatusEnumType.accepted
if result['variable']['name'] == 'Enabled':
# Q01.FR.01
assert result['attribute_value'] == 'true'
elif result['variable']['name'] == 'SupportedOperationModes':
# Q01.FR.31
# TODO(mlitre) update to check for the min requirements once they are supported: ChargingOnly, CentralSetpoint, CentralFrequency
assert result['attribute_value']
elif result['variable']['name'] == 'SupportedEnergyTransferModes':
# Q01.FR.32, we just check that it is not empty
assert result['attribute_value']
test_controller.plug_in_dc_iso()
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"StatusNotification",
call21.StatusNotification(
1, ConnectorStatusEnumType.occupied, 1, datetime.now().isoformat()
),
validate_status_notification_201,
)
@on(Action.authorize)
def on_authorize(**kwargs):
return call_result21.Authorize(
id_token_info=IdTokenInfoType(
status=AuthorizationStatusEnumType.accepted,
group_id_token=IdTokenType(
id_token="12345", type="Central"
),
),
allowed_energy_transfer=[EnergyTransferModeEnumType.dc_bpt]
)
setattr(charge_point_v21, "on_authorize", on_authorize)
central_system_v21.chargepoint.route_map = create_route_map(
central_system_v21.chargepoint
)
test_controller.swipe(id_token.id_token)
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"TransactionEvent",
{"eventType": "Started", "offline": False},
)
@on(Action.notify_ev_charging_needs)
def on_notify_ev_charging_needs(**kwargs):
return call_result21.NotifyEVChargingNeeds(
status=NotifyEVChargingNeedsStatusEnumType.rejected
)
setattr(charge_point_v21, "on_notify_ev_charging_needs",
on_notify_ev_charging_needs)
central_system_v21.chargepoint.route_map = create_route_map(
central_system_v21.chargepoint
)
# Q01.FR.03: Check all the fields of NotifyEVChargingNeeds
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"NotifyEVChargingNeeds",
{"evseId": 1, "requestedEnergyTransfer": "DC",
"controlMode": "DynamicControl", "mobilityNeedsMode": "EVCC"},
validate_notify_ev_charging_needs
)
# Check variables after NotifyEVChargingNeeds, so that we don't have to guess when the variables have been updated
ev_available = GetVariableDataType(component=ComponentType(name="ConnectedEV", evse=EVSEType(id=1)),
variable=VariableType(name="Available"),
attribute_type=AttributeEnumType.actual)
ev_vehicle_id = GetVariableDataType(component=ComponentType(name="ConnectedEV", evse=EVSEType(id=1)),
variable=VariableType(
name="VehicleId"),
attribute_type=AttributeEnumType.actual)
ev_protocol_agreed = GetVariableDataType(component=ComponentType(name="ConnectedEV", evse=EVSEType(id=1)),
variable=VariableType(
name="ProtocolAgreed"),
attribute_type=AttributeEnumType.actual)
ev_protocol_supported_by_ev = GetVariableDataType(component=ComponentType(name="ConnectedEV", evse=EVSEType(id=1)),
variable=VariableType(
name="ProtocolSupportedByEV", instance="1"),
attribute_type=AttributeEnumType.actual)
list_of_vars = [ev_available, ev_vehicle_id,
ev_protocol_agreed, ev_protocol_supported_by_ev]
r: call_result21.GetVariables = await charge_point_v21.get_variables_req(get_variable_data=list_of_vars)
# TODO(mlitre): Add check on VehicleCertificate when it is supported
# Q01.FR.36: Validate ConnectedEV variables
for result in r.get_variable_result:
assert result['attribute_status'] == GetVariableStatusEnumType.accepted
if result['variable']['name'] == 'Available':
assert result['attribute_value'] == 'true'
elif result['variable']['name'] == 'VehicleId':
# TODO(mlitre): Do we know the value before hand to check?
assert result['attribute_value']
elif result['variable']['name'] == 'ProtocolAgreed':
assert result['attribute_value'] == 'urn:iso:std:iso:15118:-20:DC,1,0'
elif result['variable']['name'] == 'ProtocolSupportedByEV':
# TODO(mlitre): How many should we check?
assert result['attribute_value']
# Q01.FR.06
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"TransactionEvent",
{"eventType": "Ended", "transactionInfo": {
"stoppedReason": "ReqEnergyTransferRejected"}, "triggerReason": "AbnormalCondition"},
)
@pytest.mark.asyncio
@pytest.mark.xdist_group(name="ISO15118")
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config("everest-config-ocpp201-sil-dc-d20-eim.yaml")
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_q02_no_service_renegotiation(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""
Q02
...
"""
log.info(
"##################### Q02: Starting in operationMode ChargingOnly before enabling V2X #################"
)
id_token = IdTokenType(id_token="8BADF00D",
type="ISO14443")
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(wait_for_bootnotification=True)
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"StatusNotification",
call21.StatusNotification(
1, ConnectorStatusEnumType.available, 1, datetime.now().isoformat()
),
validate_status_notification_201,
)
test_controller.plug_in_dc_iso()
# Make sure we don't start BPT
@on(Action.authorize)
def on_authorize(**kwargs):
return call_result21.Authorize(
id_token_info=IdTokenInfoType(
status=AuthorizationStatusEnumType.accepted,
group_id_token=IdTokenType(
id_token="12345", type="Central"
),
),
allowed_energy_transfer=[EnergyTransferModeEnumType.dc]
)
setattr(charge_point_v21, "on_authorize", on_authorize)
central_system_v21.chargepoint.route_map = create_route_map(
central_system_v21.chargepoint
)
test_controller.swipe(id_token.id_token)
r: call21.TransactionEvent = call21.TransactionEvent(
**await wait_for_and_validate(
test_utility,
charge_point_v21,
"TransactionEvent",
{"eventType": "Started"},
)
)
transaction = TransactionType(**r.transaction_info)
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"NotifyEVChargingNeeds",
{"evseId": 1, "requestedEnergyTransfer": "DC",
"controlMode": "DynamicControl", "mobilityNeedsMode": "EVCC"},
validate_notify_ev_charging_needs
)
r: call_result21.NotifyAllowedEnergyTransfer = await charge_point_v21.notify_allowed_energy_transfer_request(allowed_energy_transfer=[EnergyTransferModeEnumType.dc_bpt], transaction_id=transaction.transaction_id)
# TODO(mlitre): Once service renegotiation is supported expect Accepted instead of rejected
assert r.status == NotifyAllowedEnergyTransferStatusEnumType.rejected

View File

@@ -0,0 +1,587 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright Pionix GmbH and Contributors to EVerest
"""
Integration smoke tests for OCPP 2.1 R04 - Configure DER control settings at Charging Station.
Covers the routing/availability behaviour end-to-end over a real websocket connection:
* CS returns NotImplemented (CALLERROR) when DCDERCtrlr/ACDERCtrlr is not available
* CS returns Accepted when DCDERCtrlr.Available=true and controlType is in ModesSupported
* CS returns NotSupported when the controlType is not in ModesSupported
* GetDERControl with no stored controls returns NotFound
* ClearDERControl with no controls returns NotFound
These tests exercise the wiring done in libocpp: the DERControl functional block
is conditionally instantiated based on the DERCtrlr.Available variable, and
SetDERControl/GetDERControl/ClearDERControl messages are routed to it.
"""
import pytest
import logging
# fmt: off
from everest.testing.core_utils.controller.test_controller_interface import TestController
from ocpp.v21 import call as call21
from ocpp.v21 import call_result as call_result21
from ocpp.v21.enums import *
from ocpp.v21.datatypes import *
from ocpp.routing import on, create_route_map
from everest.testing.ocpp_utils.fixtures import *
from everest_test_utils import * # must come before v21 datatype re-imports
from ocpp.v21.enums import Action, DERControlEnumType, DERControlStatusEnumType, DERUnitEnumType
from everest.testing.core_utils._configuration.libocpp_configuration_helper import GenericOCPP2XConfigAdjustment
from everest.testing.ocpp_utils.charge_point_utils import TestUtility, OcppTestConfiguration, wait_for_and_validate
# fmt: on
log = logging.getLogger("derControlTest")
# The DER controller component configs were removed from the shipped library and
# e2e config sets pending a proper interface to provide them dynamically. Without
# DCDERCtrlr_1 present in the device model these end-to-end tests cannot configure
# DER, so skip the whole module for now. The DER behaviour stays covered by the
# libocpp unit tests (DERControlTest), which carry their own DCDERCtrlr_1 fixture.
pytestmark = pytest.mark.skip(
reason="DER controller config removed pending a dynamic DER interface; "
"covered by libocpp DERControlTest unit tests for now"
)
# -----------------------------------------------------------------------------
# R04 - DER not available: SetDERControl is rejected as NotImplemented
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
),
]
)
)
async def test_set_der_control_der_not_available_not_implemented(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""R04: Without DCDERCtrlr/ACDERCtrlr Available, SetDERControl returns CALLERROR NotImplemented.
The Python ocpp library suppresses CallErrors by default (returning None)
rather than raising, so we assert that the response is None. The underlying
CALLError is logged as a warning by the library.
"""
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
freq_droop = FreqDroopType(
priority=0,
over_freq=61.0,
under_freq=59.0,
over_droop=5.0,
under_droop=5.0,
response_time=3.0,
)
r = await charge_point_v21.set_der_control_req(
is_default=True,
control_id="ctrl-1",
control_type=DERControlEnumType.freq_droop,
freq_droop=freq_droop,
)
# CALLError NotImplemented is suppressed to None by the library
assert r is None
# -----------------------------------------------------------------------------
# R04.FR.02 - DER available + supported controlType: Accepted
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
),
(
OCPP2XConfigVariableIdentifier(
"DCDERCtrlr_1", "DCDERCtrlrAvailable", "Actual"
),
"true",
),
# ModesSupported is ReadOnly; test relies on the default list in
# DCDERCtrlr_1.json: FreqDroop,VoltWatt,LimitMaxDischarge,VoltVar,
# FixedVar,EnterService,Gradients
]
)
)
async def test_set_der_control_supported_type_accepted(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""R04.FR.02: With DCDERCtrlr Available and FreqDroop in ModesSupported, set default FreqDroop returns Accepted."""
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
freq_droop = FreqDroopType(
priority=0,
over_freq=61.0,
under_freq=59.0,
over_droop=5.0,
under_droop=5.0,
response_time=3.0,
)
r: call_result21.SetDERControl = await charge_point_v21.set_der_control_req(
is_default=True,
control_id="ctrl-default-fd",
control_type=DERControlEnumType.freq_droop,
freq_droop=freq_droop,
)
assert r.status == DERControlStatusEnumType.accepted
# -----------------------------------------------------------------------------
# R04.FR.01 - DER available but controlType not in ModesSupported: NotSupported
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
),
(
OCPP2XConfigVariableIdentifier(
"DCDERCtrlr_1", "DCDERCtrlrAvailable", "Actual"
),
"true",
),
(
OCPP2XConfigVariableIdentifier(
"DCDERCtrlr_1", "DCDERCtrlrModesSupported", "Actual"
),
# HFMustTrip is intentionally NOT in this list
"FreqDroop,VoltWatt",
),
]
)
)
async def test_set_der_control_unsupported_type_not_supported(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""R04.FR.01: With DCDERCtrlr Available but controlType not in ModesSupported, returns NotSupported.
HFMustTrip is intentionally NOT in the default ModesSupported list
(DCDERCtrlr_1.json), so this should return NotSupported.
"""
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
# HFMustTrip curve with yUnit=Not_Applicable (valid shape, unsupported type).
# NOTE: StrEnum auto-converts "Not_Applicable" -> Python attr `not__applicable` (double underscore).
curve = DERCurveType(
curve_data=[DERCurvePointsType(x=62.0, y=1.0)],
priority=0,
y_unit=DERUnitEnumType.not__applicable,
)
r: call_result21.SetDERControl = await charge_point_v21.set_der_control_req(
is_default=True,
control_id="ctrl-hf-trip",
control_type=DERControlEnumType.hf_must_trip,
curve=curve,
)
assert r.status == DERControlStatusEnumType.not_supported
# -----------------------------------------------------------------------------
# R04.FR.30 - GetDERControl with no stored controls: NotFound
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
),
(
OCPP2XConfigVariableIdentifier(
"DCDERCtrlr_1", "DCDERCtrlrAvailable", "Actual"
),
"true",
),
# ModesSupported is ReadOnly; test relies on the default list in
# DCDERCtrlr_1.json: FreqDroop,VoltWatt,LimitMaxDischarge,VoltVar,
# FixedVar,EnterService,Gradients
]
)
)
async def test_get_der_control_no_controls_not_found(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""R04.FR.30: GetDERControl when no matching controls exist returns NotFound."""
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
r: call_result21.GetDERControl = await charge_point_v21.get_der_control_req(
request_id=42,
control_type=DERControlEnumType.freq_droop,
)
assert r.status == DERControlStatusEnumType.not_found
# -----------------------------------------------------------------------------
# R04.FR.41 - ClearDERControl with no matching controls: NotFound
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
),
(
OCPP2XConfigVariableIdentifier(
"DCDERCtrlr_1", "DCDERCtrlrAvailable", "Actual"
),
"true",
),
(
OCPP2XConfigVariableIdentifier(
"DCDERCtrlr_1", "DCDERCtrlrModesSupported", "Actual"
),
"FreqDroop,VoltWatt",
),
]
)
)
async def test_clear_der_control_no_match_not_found(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""R04.FR.41: ClearDERControl when no matching controls exist returns NotFound."""
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
r: call_result21.ClearDERControl = await charge_point_v21.clear_der_control_req(
is_default=True,
control_type=DERControlEnumType.freq_droop,
)
assert r.status == DERControlStatusEnumType.not_found
# -----------------------------------------------------------------------------
# TC_R_102 end-to-end - Clearing controlTypes
# (R04.FR.02, FR.30, FR.33, FR.41, FR.44, FR.45)
#
# Exercises the cross-message state evolution that the unit tests can't:
# Set defaults of two distinct types, Clear-by-type one of them, verify the
# other survives, then Clear-all and verify the table is empty. Multi-row
# Report payload ordering itself is unit-tested in
# DERControlTest.GetDERControl_ReportOrdersMultiRowByIsSuperseded; this test
# locks in that the persisted state evolves correctly across messages.
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
),
(
OCPP2XConfigVariableIdentifier(
"DCDERCtrlr_1", "DCDERCtrlrAvailable", "Actual"
),
"true",
),
# ModesSupported is ReadOnly; relies on the default list in
# DCDERCtrlr_1.json which includes both FreqDroop and VoltWatt.
]
)
)
async def test_tc_r_102_clear_by_type_then_clear_all(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""TC_R_102: Set two defaults of distinct types, clear one by type, verify
the other survives, then clear all and verify the table is empty.
"""
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
# Step 1: SetDERControl default FreqDroop (R04.FR.02)
freq_droop = FreqDroopType(
priority=0,
over_freq=61.0,
under_freq=59.0,
over_droop=5.0,
under_droop=5.0,
response_time=3.0,
)
r: call_result21.SetDERControl = await charge_point_v21.set_der_control_req(
is_default=True,
control_id="ctrl-default-fd",
control_type=DERControlEnumType.freq_droop,
freq_droop=freq_droop,
)
assert r.status == DERControlStatusEnumType.accepted
# Step 2: SetDERControl default VoltWatt
volt_watt_curve = DERCurveType(
curve_data=[DERCurvePointsType(x=240.0, y=100.0)],
priority=1,
y_unit=DERUnitEnumType.pct_maxw,
)
r = await charge_point_v21.set_der_control_req(
is_default=True,
control_id="ctrl-default-vw",
control_type=DERControlEnumType.volt_watt,
curve=volt_watt_curve,
)
assert r.status == DERControlStatusEnumType.accepted
# Step 3: GetDERControl FreqDroop -> Accepted (the row exists)
r_get: call_result21.GetDERControl = await charge_point_v21.get_der_control_req(
request_id=1,
is_default=True,
control_type=DERControlEnumType.freq_droop,
)
assert r_get.status == DERControlStatusEnumType.accepted
# Step 4: GetDERControl VoltWatt -> Accepted (the row exists)
r_get = await charge_point_v21.get_der_control_req(
request_id=2,
is_default=True,
control_type=DERControlEnumType.volt_watt,
)
assert r_get.status == DERControlStatusEnumType.accepted
# Step 5: ClearDERControl by type=FreqDroop (R04.FR.45)
r_clear: call_result21.ClearDERControl = (
await charge_point_v21.clear_der_control_req(
is_default=True,
control_type=DERControlEnumType.freq_droop,
)
)
assert r_clear.status == DERControlStatusEnumType.accepted
# Step 6: GetDERControl FreqDroop -> NotFound (the row was cleared, R04.FR.30)
r_get = await charge_point_v21.get_der_control_req(
request_id=3,
is_default=True,
control_type=DERControlEnumType.freq_droop,
)
assert r_get.status == DERControlStatusEnumType.not_found
# Step 7: GetDERControl VoltWatt -> Accepted (untouched by the targeted clear)
r_get = await charge_point_v21.get_der_control_req(
request_id=4,
is_default=True,
control_type=DERControlEnumType.volt_watt,
)
assert r_get.status == DERControlStatusEnumType.accepted
# Step 8: ClearDERControl with no controlType / controlId,
# isDefault=true -> clear ALL matching defaults (R04.FR.44)
r_clear = await charge_point_v21.clear_der_control_req(is_default=True)
assert r_clear.status == DERControlStatusEnumType.accepted
# Step 9: GetDERControl any default -> NotFound (table is empty)
r_get = await charge_point_v21.get_der_control_req(
request_id=5,
is_default=True,
control_type=DERControlEnumType.volt_watt,
)
assert r_get.status == DERControlStatusEnumType.not_found
# -----------------------------------------------------------------------------
# TC_R_103 - SetDERControl with unsupported controlType returns NotSupported.
# (R04.FR.01) Locks in the spec contract that prior log analysis showed the
# certification tool can violate by picking a supported type for this step.
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
),
(
OCPP2XConfigVariableIdentifier(
"DCDERCtrlr_1", "DCDERCtrlrAvailable", "Actual"
),
"true",
),
(
OCPP2XConfigVariableIdentifier(
"DCDERCtrlr_1", "DCDERCtrlrModesSupported", "Actual"
),
# WattVar deliberately omitted.
"FreqDroop,VoltWatt,VoltVar,FixedVar,LimitMaxDischarge,EnterService,Gradients",
),
]
)
)
async def test_tc_r_103_unsupported_control_type_returns_not_supported(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""TC_R_103 step 2: SetDERControl with controlType not in ModesSupported -> NotSupported."""
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
watt_var_curve = DERCurveType(
curve_data=[DERCurvePointsType(x=100.0, y=50.0)],
priority=0,
y_unit=DERUnitEnumType.pct_max_var,
)
r: call_result21.SetDERControl = await charge_point_v21.set_der_control_req(
is_default=True,
control_id="ctrl-wv",
control_type=DERControlEnumType.watt_var,
curve=watt_var_curve,
)
assert r.status == DERControlStatusEnumType.not_supported
# -----------------------------------------------------------------------------
# TC_R_104 - GetDERControl Accepted MUST be followed by an outbound
# ReportDERControl message. (R04.FR.32-33). Regression guard for a libocpp bug
# where MessageType::ReportDERControl was missing from the enum, causing the
# outbound Call to throw and emit a FormationViolation CallError instead.
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
),
(
OCPP2XConfigVariableIdentifier(
"DCDERCtrlr_1", "DCDERCtrlrAvailable", "Actual"
),
"true",
),
]
)
)
async def test_tc_r_104_get_emits_report_der_control(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""TC_R_104: SetDERControl(default FreqDroop), then GetDERControl. CS must
respond with GetDERControlResponse(status=Accepted) AND then emit a separate
ReportDERControl message carrying the stored control.
"""
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
freq_droop = FreqDroopType(
priority=0,
over_freq=61.0,
under_freq=59.0,
over_droop=5.0,
under_droop=5.0,
response_time=3.0,
)
r_set: call_result21.SetDERControl = await charge_point_v21.set_der_control_req(
is_default=True,
control_id="ctrl-r104",
control_type=DERControlEnumType.freq_droop,
freq_droop=freq_droop,
)
assert r_set.status == DERControlStatusEnumType.accepted
r_get: call_result21.GetDERControl = await charge_point_v21.get_der_control_req(
request_id=104,
is_default=True,
control_type=DERControlEnumType.freq_droop,
)
assert r_get.status == DERControlStatusEnumType.accepted
# The wire-level message that the libocpp bug used to suppress.
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"ReportDERControl",
{"requestId": 104},
)

View File

@@ -0,0 +1,971 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright Pionix GmbH and Contributors to EVerest
import pytest
from datetime import datetime, timezone
import traceback
# fmt: off
import logging
from everest.testing.core_utils.controller.test_controller_interface import TestController
from ocpp.v21 import call as call21
from ocpp.v21 import call_result as call_result21
from ocpp.v21.enums import *
from ocpp.v21.datatypes import *
from ocpp.routing import on, create_route_map
from everest.testing.ocpp_utils.fixtures import *
from everest_test_utils import * # Needs to be before the datatypes below since it overrides the v21 Action enum with the v16 one
from ocpp.v21.enums import (
Action,
ConnectorStatusEnumType,
AttributeEnumType,
)
from validations import validate_status_notification_201
from everest.testing.core_utils._configuration.libocpp_configuration_helper import GenericOCPP2XConfigAdjustment
from everest.testing.ocpp_utils.charge_point_utils import wait_for_and_validate, TestUtility, OcppTestConfiguration
# fmt: on
log = logging.getLogger("provisioningTest")
def validate_set_variables_success(response, expected_count=1):
"""Validate SetVariables response indicates success.
response is a call_result.SetVariables with set_variable_result as list of dicts.
"""
if not response or not response.set_variable_result:
return False
success_count = sum(
1
for r in response.set_variable_result
if r.get("attribute_status") == "Accepted"
)
return success_count >= expected_count
def validate_set_variables_rejected(response, expected_reason=None):
"""Validate SetVariables response indicates rejection.
response is a call_result.SetVariables with set_variable_result as list of dicts.
"""
if not response or not response.set_variable_result:
return False
for result in response.set_variable_result:
status = result.get("attribute_status")
if status != "Accepted":
if expected_reason:
status_info = result.get("attribute_status_info") or {}
reason = status_info.get("reason_code", "").upper()
return expected_reason.upper() in reason
return True
return False
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_cold_boot_01(
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
"""
B01.FR.01
...
"""
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
try:
# expect StatusNotification with status available
assert await wait_for_and_validate(
test_utility,
charge_point_v21,
"StatusNotification",
call21.StatusNotification(
1, ConnectorStatusEnumType.available, 1, datetime.now().isoformat()
),
validate_status_notification_201,
)
except Exception as e:
traceback.print_exc()
logging.critical(e)
# TOOD(piet): Check configured HeartbeatInterval of BootNotificationResponse
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_cold_boot_pending_01(
test_config: OcppTestConfiguration,
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
@on(Action.boot_notification)
def on_boot_notification_pending(**kwargs):
return call_result21.BootNotification(
current_time=datetime.now().isoformat(),
interval=5,
status=RegistrationStatusEnumType.pending,
)
@on(Action.boot_notification)
def on_boot_notification_accepted(**kwargs):
return call_result21.BootNotification(
current_time=datetime.now(timezone.utc).isoformat(),
interval=5,
status=RegistrationStatusEnumType.accepted,
)
test_utility.forbidden_actions.append("SecurityEventNotification")
central_system_v21.function_overrides.append(
("on_boot_notification", on_boot_notification_pending)
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
setattr(charge_point_v21, "on_boot_notification",
on_boot_notification_accepted)
central_system_v21.chargepoint.route_map = create_route_map(
central_system_v21.chargepoint
)
assert await wait_for_and_validate(
test_utility, charge_point_v21, "BootNotification", {}
)
test_utility.forbidden_actions.clear()
test_controller.plug_in()
assert await wait_for_and_validate(
test_utility, charge_point_v21, "SecurityEventNotification", {}
)
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_cold_boot_rejected_01(
test_config: OcppTestConfiguration,
central_system_v21: CentralSystem,
test_controller: TestController,
test_utility: TestUtility,
):
@on(Action.boot_notification)
def on_boot_notification_pending(**kwargs):
return call_result21.BootNotification(
current_time=datetime.now().isoformat(),
interval=5,
status=RegistrationStatusEnumType.rejected,
)
@on(Action.boot_notification)
def on_boot_notification_accepted(**kwargs):
return call_result21.BootNotification(
current_time=datetime.now(timezone.utc).isoformat(),
interval=5,
status=RegistrationStatusEnumType.accepted,
)
central_system_v21.function_overrides.append(
("on_boot_notification", on_boot_notification_pending)
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint()
setattr(charge_point_v21, "on_boot_notification",
on_boot_notification_accepted)
central_system_v21.chargepoint.route_map = create_route_map(
central_system_v21.chargepoint
)
assert await wait_for_and_validate(
test_utility, charge_point_v21, "BootNotification", {}
)
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_get_network_configuration_slot_1(
central_system_v21: CentralSystem,
test_controller: TestController,
):
"""
B09.FR.01 - Get NetworkConfiguration for slot 1 (primary configuration)
Verify that GetVariables can retrieve NetworkConfiguration[1] variables
"""
log.info(
"##################### B09.FR.01: Get NetworkConfiguration Slot 1 #################"
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(
wait_for_bootnotification=True
)
# Request OcppCsmsUrl for NetworkConfiguration slot 1
get_var = GetVariableDataType(
component=ComponentType(
name="NetworkConfiguration", instance="1"
),
variable=VariableType(name="OcppCsmsUrl"),
attribute_type=AttributeEnumType.actual,
)
response = await charge_point_v21.get_variables_req(
get_variable_data=[get_var]
)
assert response and response.get_variable_result, "No get variable result"
results = response.get_variable_result
assert len(results) > 0, "Empty get variable result"
assert (
results[0].get("attribute_status") == "Accepted"
), f"Failed to get OcppCsmsUrl: {results[0]}"
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_set_network_configuration_slot_2(
central_system_v21: CentralSystem,
test_controller: TestController,
):
"""
B09.FR.09 - Set NetworkConfiguration for slot 2 (backup configuration)
Verify that SetVariables can update NetworkConfiguration[2] variables
which are not in the priority list
"""
log.info(
"##################### B09.FR.09: Set NetworkConfiguration Slot 2 #################"
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(
wait_for_bootnotification=True
)
# Set OcppCsmsUrl for NetworkConfiguration slot 2
set_var = SetVariableDataType(
component=ComponentType(
name="NetworkConfiguration", instance="2"
),
variable=VariableType(name="OcppCsmsUrl"),
attribute_type=AttributeEnumType.actual,
attribute_value="wss://backup-csms.example.com/ocpp",
)
response = await charge_point_v21.set_variables_req(
set_variable_data=[set_var]
)
assert validate_set_variables_success(response, 1), \
f"Failed to set OcppCsmsUrl on slot 2: {response}"
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_get_all_network_configuration_variables_slot_1(
central_system_v21: CentralSystem,
test_controller: TestController,
):
"""
Verify GetVariables can retrieve all NetworkConfiguration[1] variables
"""
log.info(
"##################### Get All NetworkConfiguration[1] Variables #################"
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(
wait_for_bootnotification=True
)
# Request all main configuration variables for slot 1
variable_names = [
"OcppCsmsUrl",
"SecurityProfile",
"OcppInterface",
"OcppTransport",
"MessageTimeout",
]
get_vars = [
GetVariableDataType(
component=ComponentType(
name="NetworkConfiguration", instance="1"
),
variable=VariableType(name=var_name),
attribute_type=AttributeEnumType.actual,
)
for var_name in variable_names
]
response = await charge_point_v21.get_variables_req(
get_variable_data=get_vars
)
assert response and response.get_variable_result, "No get variable result"
results = response.get_variable_result
assert len(results) == len(variable_names), "Not all variables returned"
# Count successful retrievals
success_count = sum(
1
for r in results
if r.get("attribute_status") == "Accepted"
)
assert (
success_count == len(variable_names)
), f"Some variables failed: {results}"
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_network_configuration_priority_list_management(
central_system_v21: CentralSystem,
test_controller: TestController,
):
"""
TC_B_107_CS: Add and remove slots from NetworkConfigurationPriority list
B09.FR.21, B09.FR.22, B09.FR.23 - Verify that CSMS can manage which configuration slots
are in the priority list, and verify that configuration values are consistent when
slots are added/removed from the priority list.
"""
log.info(
"##################### TC_B_107_CS: NetworkConfigurationPriority List Management #################"
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(
wait_for_bootnotification=True
)
# First, set a configuration on slot 2 (which is not in priority by default)
set_vars = [
SetVariableDataType(
component=ComponentType(name="NetworkConfiguration", instance="2"),
variable=VariableType(name="OcppCsmsUrl"),
attribute_type=AttributeEnumType.actual,
attribute_value="wss://backup-csms.example.com/ocpp",
),
SetVariableDataType(
component=ComponentType(name="NetworkConfiguration", instance="2"),
variable=VariableType(name="SecurityProfile"),
attribute_type=AttributeEnumType.actual,
attribute_value="1",
),
]
response = await charge_point_v21.set_variables_req(
set_variable_data=set_vars
)
assert validate_set_variables_success(
response, len(set_vars)
), f"Failed to set initial configuration on slot 2: {response}"
# Verify the configuration was set
get_var = GetVariableDataType(
component=ComponentType(name="NetworkConfiguration", instance="2"),
variable=VariableType(name="OcppCsmsUrl"),
attribute_type=AttributeEnumType.actual,
)
response = await charge_point_v21.get_variables_req(
get_variable_data=[get_var]
)
assert response and response.get_variable_result, "No get variable result"
results = response.get_variable_result
assert len(results) > 0, "Empty get variable result"
assert (
results[0].get("attribute_status") == "Accepted"
), f"Failed to get OcppCsmsUrl from slot 2: {results[0]}"
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_network_configuration_reject_priority_slot_changes(
central_system_v21: CentralSystem,
test_controller: TestController,
):
"""
TC_B_108_CS: Reject SetVariables on the currently active NetworkConfiguration slot
B09.FR.22 - Verify that attempts to change configuration on the active slot
are rejected with "PriorityNetworkConf" reason code.
"""
log.info(
"##################### TC_B_108_CS: Reject Changes to Priority Slots #################"
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(
wait_for_bootnotification=True
)
# Attempt to change NetworkConfiguration slot 1 (which is in priority by default)
# This should be rejected with "PriorityNetworkConf" reason
set_var = SetVariableDataType(
component=ComponentType(name="NetworkConfiguration", instance="1"),
variable=VariableType(name="OcppCsmsUrl"),
attribute_type=AttributeEnumType.actual,
attribute_value="wss://new-csms.example.com/ocpp",
)
response = await charge_point_v21.set_variables_req(
set_variable_data=[set_var]
)
# Validate that the change was rejected with PriorityNetworkConf reason
assert validate_set_variables_rejected(
response, "PriorityNetworkConf"
), f"Expected SetVariables to be rejected with PriorityNetworkConf, but got: {response}"
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_network_configuration_security_downgrade_rejection_set_network_profile(
central_system_v21: CentralSystem,
test_controller: TestController,
):
"""
TC_B_110_CS: Reject security profile downgrade via SetNetworkProfileRequest
B09.FR.31 - Verify that SetNetworkProfileRequest is rejected with "NoSecurityDowngrade"
reason when attempting to downgrade the active security profile.
"""
log.info(
"##################### TC_B_110_CS: Reject Security Downgrade via SetNetworkProfileRequest #################"
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(
wait_for_bootnotification=True
)
# First, get the current active security profile from slot 1
get_var = GetVariableDataType(
component=ComponentType(name="NetworkConfiguration", instance="1"),
variable=VariableType(name="SecurityProfile"),
attribute_type=AttributeEnumType.actual,
)
response = await charge_point_v21.get_variables_req(
get_variable_data=[get_var]
)
assert response and response.get_variable_result, "No get variable result"
results = response.get_variable_result
assert len(results) > 0, "Empty get variable result"
# Try to set a lower security profile on slot 2
# Assuming the current active profile is 1, try to set slot 2 to 0 (which would be lower)
set_vars = [
SetVariableDataType(
component=ComponentType(name="NetworkConfiguration", instance="2"),
variable=VariableType(name="SecurityProfile"),
attribute_type=AttributeEnumType.actual,
attribute_value="0", # Downgrade attempt
),
]
response = await charge_point_v21.set_variables_req(
set_variable_data=set_vars
)
# B09.FR.31: Downgrade attempt should be rejected with NoSecurityDowngrade
assert validate_set_variables_rejected(
response, "NoSecurityDowngrade"
), f"Expected security downgrade to be rejected with NoSecurityDowngrade, but got: {response}"
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_network_configuration_security_downgrade_rejection_set_variables(
central_system_v21: CentralSystem,
test_controller: TestController,
):
"""
TC_B_111_CS: Reject security profile downgrade via SetVariables on NetworkConfiguration
B09.FR.32 - Verify that SetVariables is rejected with "NoSecurityDowngrade" reason
when attempting to downgrade the SecurityProfile variable on an active configuration slot.
"""
log.info(
"##################### TC_B_111_CS: Reject Security Downgrade via SetVariables #################"
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(
wait_for_bootnotification=True
)
# First, set a higher security profile on slot 2
set_var = SetVariableDataType(
component=ComponentType(name="NetworkConfiguration", instance="2"),
variable=VariableType(name="SecurityProfile"),
attribute_type=AttributeEnumType.actual,
attribute_value="2", # Set to profile 2
)
response = await charge_point_v21.set_variables_req(
set_variable_data=[set_var]
)
# This should succeed if slot 2 is not active
assert validate_set_variables_success(
response, 1
), f"Failed to set initial security profile: {response}"
# Now try to downgrade the security profile on slot 2
set_var = SetVariableDataType(
component=ComponentType(name="NetworkConfiguration", instance="2"),
variable=VariableType(name="SecurityProfile"),
attribute_type=AttributeEnumType.actual,
attribute_value="0", # Attempt downgrade
)
response = await charge_point_v21.set_variables_req(
set_variable_data=[set_var]
)
# B09.FR.32: Downgrade attempt on slot 2 after setting profile 2 should be rejected
assert validate_set_variables_rejected(
response, "NoSecurityDowngrade"
), f"Expected security downgrade to be rejected with NoSecurityDowngrade, but got: {response}"
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_network_configuration_allow_security_downgrade_flag(
central_system_v21: CentralSystem,
test_controller: TestController,
):
"""
TC_B_112_CS: AllowSecurityDowngrade configuration flag behavior
B09.FR.04, B09.FR.31 - Verify that when AllowSecurityDowngrade is set to false,
all security profile downgrades are rejected regardless of whether they occur
via SetNetworkProfileRequest or SetVariables on NetworkConfiguration.
"""
log.info(
"##################### TC_B_112_CS: AllowSecurityDowngrade Flag Enforcement #################"
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(
wait_for_bootnotification=True
)
# Check the current AllowSecurityDowngrade setting
get_var = GetVariableDataType(
component=ComponentType(name="SecurityCtrlr"),
variable=VariableType(name="AllowSecurityDowngrade"),
attribute_type=AttributeEnumType.actual,
)
response = await charge_point_v21.get_variables_req(
get_variable_data=[get_var]
)
# Verify we can read the AllowSecurityDowngrade variable
assert response and response.get_variable_result, "No get variable result"
results = response.get_variable_result
assert len(results) > 0, "Empty get variable result"
# When AllowSecurityDowngrade is false, any downgrade should be rejected
# Try to set a lower security profile
set_var = SetVariableDataType(
component=ComponentType(name="NetworkConfiguration", instance="2"),
variable=VariableType(name="SecurityProfile"),
attribute_type=AttributeEnumType.actual,
attribute_value="0",
)
response = await charge_point_v21.set_variables_req(
set_variable_data=[set_var]
)
# B09.FR.04, B09.FR.31: When AllowSecurityDowngrade is false, any downgrade should be rejected
assert validate_set_variables_rejected(
response, "NoSecurityDowngrade"
), f"Expected security downgrade to be rejected with NoSecurityDowngrade, but got: {response}"
@pytest.mark.asyncio
@pytest.mark.ocpp_version("ocpp2.1")
@pytest.mark.everest_core_config(
get_everest_config_path_str("everest-config-ocpp201.yaml")
)
@pytest.mark.ocpp_config_adaptions(
GenericOCPP2XConfigAdjustment(
[
(
OCPP2XConfigVariableIdentifier(
"InternalCtrlr", "SupportedOcppVersions", "Actual"
),
"ocpp2.1",
)
]
)
)
async def test_network_configuration_dm_end_to_end(
central_system_v21: CentralSystem,
test_controller: TestController,
):
"""
US-006: End-to-end integration test for NetworkConfiguration device model.
Verifies the full lifecycle:
1. Migration from legacy NetworkConnectionProfiles blob to DM variables on boot
2. GetVariables reads correct values from NetworkConfiguration[1]
3. SetVariables updates a non-active slot and the change persists (read-back)
4. SetVariables on the active slot is rejected with PriorityNetworkConf
"""
log.info(
"##################### US-006: NetworkConfiguration DM End-to-End #################"
)
test_controller.start()
charge_point_v21 = await central_system_v21.wait_for_chargepoint(
wait_for_bootnotification=True
)
# ── Step 1 & 2: Verify migration happened by reading slot 1 DM variables ──
# The charge point booted with a legacy NetworkConnectionProfiles blob.
# Migration should have populated NetworkConfiguration[1] with those values.
variable_names = ["OcppCsmsUrl", "SecurityProfile", "OcppInterface", "OcppTransport"]
get_vars = [
GetVariableDataType(
component=ComponentType(
name="NetworkConfiguration", instance="1"
),
variable=VariableType(name=var_name),
attribute_type=AttributeEnumType.actual,
)
for var_name in variable_names
]
response = await charge_point_v21.get_variables_req(
get_variable_data=get_vars
)
assert response and response.get_variable_result, "No get variable result for slot 1"
results = response.get_variable_result
assert len(results) == len(variable_names), (
f"Expected {len(variable_names)} results, got {len(results)}"
)
# Build a map of variable name → value for easier assertions
slot1_values = {}
for r in results:
var_name = r.get("variable", {}).get("name")
status = r.get("attribute_status")
assert status == "Accepted", (
f"GetVariables failed for {var_name}: status={status}"
)
slot1_values[var_name] = r.get("attribute_value")
# Verify migration produced correct values from the legacy blob
# The legacy blob has: securityProfile=1, ocppInterface=Wired0, ocppTransport=JSON
# OcppCsmsUrl is injected by the test framework to point to the test CSMS
assert slot1_values["OcppCsmsUrl"], "OcppCsmsUrl should not be empty after migration"
assert "ws" in slot1_values["OcppCsmsUrl"].lower(), (
f"OcppCsmsUrl should be a websocket URL, got: {slot1_values['OcppCsmsUrl']}"
)
assert slot1_values["SecurityProfile"] == "1", (
f"SecurityProfile should be 1, got: {slot1_values['SecurityProfile']}"
)
assert slot1_values["OcppInterface"] == "Wired0", (
f"OcppInterface should be Wired0, got: {slot1_values['OcppInterface']}"
)
assert slot1_values["OcppTransport"] == "JSON", (
f"OcppTransport should be JSON, got: {slot1_values['OcppTransport']}"
)
log.info("Step 1-2 PASSED: Migration verified — slot 1 DM variables match legacy blob")
# ── Step 3: SetVariables on non-active slot 2, then read back to verify persistence ──
new_url = "wss://updated-backup.example.com/ocpp"
new_security_profile = "2"
new_interface = "Wired0"
new_transport = "JSON"
set_vars = [
SetVariableDataType(
component=ComponentType(
name="NetworkConfiguration", instance="2"
),
variable=VariableType(name="OcppCsmsUrl"),
attribute_type=AttributeEnumType.actual,
attribute_value=new_url,
),
SetVariableDataType(
component=ComponentType(
name="NetworkConfiguration", instance="2"
),
variable=VariableType(name="SecurityProfile"),
attribute_type=AttributeEnumType.actual,
attribute_value=new_security_profile,
),
SetVariableDataType(
component=ComponentType(
name="NetworkConfiguration", instance="2"
),
variable=VariableType(name="OcppInterface"),
attribute_type=AttributeEnumType.actual,
attribute_value=new_interface,
),
SetVariableDataType(
component=ComponentType(
name="NetworkConfiguration", instance="2"
),
variable=VariableType(name="OcppTransport"),
attribute_type=AttributeEnumType.actual,
attribute_value=new_transport,
),
]
response = await charge_point_v21.set_variables_req(
set_variable_data=set_vars
)
assert validate_set_variables_success(response, len(set_vars)), (
f"SetVariables on slot 2 should succeed: {response}"
)
# Read back slot 2 to verify persistence
get_vars_slot2 = [
GetVariableDataType(
component=ComponentType(
name="NetworkConfiguration", instance="2"
),
variable=VariableType(name=var_name),
attribute_type=AttributeEnumType.actual,
)
for var_name in variable_names
]
response = await charge_point_v21.get_variables_req(
get_variable_data=get_vars_slot2
)
assert response and response.get_variable_result, "No get variable result for slot 2"
results = response.get_variable_result
slot2_values = {}
for r in results:
var_name = r.get("variable", {}).get("name")
status = r.get("attribute_status")
assert status == "Accepted", (
f"GetVariables failed for slot 2 {var_name}: status={status}"
)
slot2_values[var_name] = r.get("attribute_value")
assert slot2_values["OcppCsmsUrl"] == new_url, (
f"Slot 2 OcppCsmsUrl should be {new_url}, got: {slot2_values['OcppCsmsUrl']}"
)
assert slot2_values["SecurityProfile"] == new_security_profile, (
f"Slot 2 SecurityProfile should be {new_security_profile}, got: {slot2_values['SecurityProfile']}"
)
assert slot2_values["OcppInterface"] == new_interface, (
f"Slot 2 OcppInterface should be {new_interface}, got: {slot2_values['OcppInterface']}"
)
assert slot2_values["OcppTransport"] == new_transport, (
f"Slot 2 OcppTransport should be {new_transport}, got: {slot2_values['OcppTransport']}"
)
log.info("Step 3 PASSED: SetVariables on slot 2 persisted and verified via GetVariables")
# ── Step 4: SetVariables on the active slot (1) should be rejected ──
set_var_active = SetVariableDataType(
component=ComponentType(
name="NetworkConfiguration", instance="1"
),
variable=VariableType(name="OcppCsmsUrl"),
attribute_type=AttributeEnumType.actual,
attribute_value="wss://should-be-rejected.example.com/ocpp",
)
response = await charge_point_v21.set_variables_req(
set_variable_data=[set_var_active]
)
assert validate_set_variables_rejected(
response, "PriorityNetworkConf"
), (
f"SetVariables on active slot 1 should be rejected with "
f"PriorityNetworkConf, but got: {response}"
)
log.info("Step 4 PASSED: SetVariables on active slot 1 rejected with PriorityNetworkConf")