From a9d1ab08754a661027cbbded37cf399d424f42b8 Mon Sep 17 00:00:00 2001 From: Maaike Zijderveld Date: Mon, 16 Dec 2024 18:12:09 +0100 Subject: [PATCH] Cost and price display message tests (#975) * Add cost and price and display message tests. Signed-off-by: Maaike Zijderveld, iolar --- .../everest-config-ocpp16-costandprice.yaml | 122 +++ .../everest-config-ocpp201-costandprice.yaml | 162 +++ .../config/libocpp-config-costandprice.json | 93 ++ .../everest_test_utils_probe_modules.py | 75 ++ .../ocpp16/california_pricing_ocpp16.py | 953 ++++++++++++++++++ .../test_sets/ocpp16/ocpp_compliance_tests.py | 3 - .../test_sets/ocpp201/california_pricing.py | 563 +++++++++++ .../test_sets/ocpp201/display_message.py | 391 +++++++ 8 files changed, 2359 insertions(+), 3 deletions(-) create mode 100644 tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp16-costandprice.yaml create mode 100644 tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp201-costandprice.yaml create mode 100644 tests/ocpp_tests/test_sets/everest-aux/config/libocpp-config-costandprice.json create mode 100644 tests/ocpp_tests/test_sets/everest_test_utils_probe_modules.py create mode 100644 tests/ocpp_tests/test_sets/ocpp16/california_pricing_ocpp16.py create mode 100644 tests/ocpp_tests/test_sets/ocpp201/california_pricing.py create mode 100644 tests/ocpp_tests/test_sets/ocpp201/display_message.py diff --git a/tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp16-costandprice.yaml b/tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp16-costandprice.yaml new file mode 100644 index 000000000..d3ff8a54f --- /dev/null +++ b/tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp16-costandprice.yaml @@ -0,0 +1,122 @@ +active_modules: + evse_manager: + module: EvseManager + config_module: + connector_id: 1 + has_ventilation: true + evse_id: "1" + external_ready_to_start_charging: true + connections: + bsp: + - module_id: yeti_driver + implementation_id: board_support + powermeter_grid_side: + - module_id: yeti_driver + implementation_id: powermeter + yeti_driver: + module: JsYetiSimulator + config_module: + connector_id: 1 + ev_manager: + module: EvManager + config_module: + connector_id: 1 + auto_enable: true + auto_exec: false + auto_exec_commands: sleep 1;iec_wait_pwr_ready;sleep 1;draw_power_regulated 16,3;sleep 30;unplug + connections: + ev_board_support: + - module_id: yeti_driver + implementation_id: ev_board_support + auth: + module: Auth + config_module: + connection_timeout: 10 + selection_algorithm: FindFirst + connections: + token_provider: + - module_id: token_provider + implementation_id: main + - module_id: ocpp + implementation_id: auth_provider + token_validator: + - module_id: ocpp + implementation_id: auth_validator + evse_manager: + - module_id: evse_manager + implementation_id: evse + ocpp: + module: OCPP + config_module: + ChargePointConfigPath: libocpp-config-costandprice.json + connections: + evse_manager: + - module_id: evse_manager + implementation_id: evse + reservation: + - module_id: auth + implementation_id: reservation + auth: + - module_id: auth + implementation_id: main + system: + - module_id: system + implementation_id: main + security: + - module_id: evse_security + implementation_id: main + display_message: + - module_id: display_message + implementation_id: display_message + display_message: + module: TerminalCostAndPriceMessage + connections: + session_cost: + - module_id: ocpp + implementation_id: session_cost + evse_security: + module: EvseSecurity + config_module: + private_key_password: "123456" + token_provider: + module: DummyTokenProviderManual + energy_manager: + module: EnergyManager + connections: + energy_trunk: + - module_id: grid_connection_point + implementation_id: energy_grid + grid_connection_point: + module: EnergyNode + config_module: + fuse_limit_A: 40.0 + phase_count: 3 + connections: + price_information: [] + energy_consumer: + - module_id: evse_manager + implementation_id: energy_grid + powermeter: + - module_id: yeti_driver + implementation_id: powermeter + api: + module: API + connections: + evse_manager: + - module_id: evse_manager + implementation_id: evse + ocpp: + - module_id: ocpp + implementation_id: ocpp_generic + error_history: + - module_id: error_history + implementation_id: error_history + error_history: + module: ErrorHistory + config_implementation: + error_history: + database_path: /tmp/error_history.db + system: + module: System + +x-module-layout: {} diff --git a/tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp201-costandprice.yaml b/tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp201-costandprice.yaml new file mode 100644 index 000000000..77b3ab771 --- /dev/null +++ b/tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp201-costandprice.yaml @@ -0,0 +1,162 @@ +active_modules: + connector_1: + module: EvseManager + config_module: + connector_id: 1 + has_ventilation: true + evse_id: "1" + session_logging: true + session_logging_xml: false + ac_hlc_enabled: false + ac_hlc_use_5percent: false + ac_enforce_hlc: false + connections: + bsp: + - module_id: yeti_driver_1 + implementation_id: board_support + powermeter_grid_side: + - module_id: yeti_driver_1 + implementation_id: powermeter + connector_2: + module: EvseManager + config_module: + connector_id: 2 + has_ventilation: true + evse_id: "2" + session_logging: true + session_logging_xml: false + ac_hlc_enabled: false + ac_hlc_use_5percent: false + ac_enforce_hlc: false + connections: + bsp: + - module_id: yeti_driver_2 + implementation_id: board_support + powermeter_grid_side: + - module_id: yeti_driver_2 + implementation_id: powermeter + yeti_driver_1: + module: JsYetiSimulator + config_module: + connector_id: 1 + yeti_driver_2: + module: JsYetiSimulator + config_module: + connector_id: 2 + auth: + module: Auth + config_module: + connection_timeout: 30 + selection_algorithm: FindFirst + connections: + token_provider: + - module_id: ocpp + implementation_id: auth_provider + - module_id: token_provider_manual + implementation_id: main + token_validator: + - module_id: ocpp + implementation_id: auth_validator + evse_manager: + - module_id: connector_1 + implementation_id: evse + - module_id: connector_2 + implementation_id: evse + ocpp: + module: OCPP201 + config_module: + EnableExternalWebsocketControl: true + connections: + evse_manager: + - module_id: connector_1 + implementation_id: evse + - module_id: connector_2 + implementation_id: evse + auth: + - module_id: auth + implementation_id: main + system: + - module_id: system + implementation_id: main + security: + - module_id: evse_security + implementation_id: main + display_message: + - module_id: display_message + implementation_id: display_message + persistent_store: + module: PersistentStore + config_module: + sqlite_db_file_path: persistent_store.db + display_message: + module: TerminalCostAndPriceMessage + connections: + session_cost: + - module_id: ocpp + implementation_id: session_cost + evse_security: + module: EvseSecurity + config_module: + csms_ca_bundle: "ca/csms/CSMS_ROOT_CA.pem" + csms_leaf_cert_directory: "client/csms" + csms_leaf_key_directory: "client/csms" + mf_ca_bundle: "ca/mf/MF_ROOT_CA.pem" + mo_ca_bundle: "ca/mo/MO_ROOT_CA.pem" + v2g_ca_bundle: "ca/v2g/V2G_ROOT_CA.pem" + secc_leaf_cert_directory: "client/cso" + secc_leaf_key_directory: "client/cso" + private_key_password: "123456" + token_provider_manual: + module: DummyTokenProviderManual + energy_manager: + module: EnergyManager + connections: + energy_trunk: + - module_id: grid_connection_point + implementation_id: energy_grid + grid_connection_point: + module: EnergyNode + config_module: + fuse_limit_A: 40.0 + phase_count: 3 + connections: + price_information: [] + energy_consumer: + - module_id: connector_1 + implementation_id: energy_grid + - module_id: connector_2 + implementation_id: energy_grid + powermeter: + - module_id: yeti_driver_1 + implementation_id: powermeter + ev_manager_1: + module: EvManager + config_module: + connector_id: 1 + auto_enable: true + auto_exec: false + auto_exec_commands: sleep 1;iec_wait_pwr_ready;sleep 1;draw_power_regulated 16,3;sleep 30;unplug + connections: + ev_board_support: + - module_id: yeti_driver_1 + implementation_id: ev_board_support + ev_manager_2: + module: EvManager + config_module: + connector_id: 2 + auto_enable: true + auto_exec: false + auto_exec_commands: sleep 1;iec_wait_pwr_ready;sleep 1;draw_power_regulated 16,3;sleep 30;unplug + connections: + ev_board_support: + - module_id: yeti_driver_2 + implementation_id: ev_board_support + error_history: + module: ErrorHistory + config_implementation: + error_history: + database_path: /tmp/error_history.db + system: + module: System + +x-module-layout: {} diff --git a/tests/ocpp_tests/test_sets/everest-aux/config/libocpp-config-costandprice.json b/tests/ocpp_tests/test_sets/everest-aux/config/libocpp-config-costandprice.json new file mode 100644 index 000000000..019dff473 --- /dev/null +++ b/tests/ocpp_tests/test_sets/everest-aux/config/libocpp-config-costandprice.json @@ -0,0 +1,93 @@ +{ + "Internal": { + "ChargePointId": "cp001", + "CentralSystemURI": "127.0.0.1:9000/cp001", + "ChargeBoxSerialNumber": "cp001", + "ChargePointModel": "Yeti", + "ChargePointVendor": "Pionix", + "FirmwareVersion": "0.1", + "LogMessages": true + }, + "Core": { + "AuthorizeRemoteTxRequests": false, + "ClockAlignedDataInterval": 900, + "ConnectionTimeOut": 30, + "ConnectorPhaseRotation": "0.RST,1.RST", + "GetConfigurationMaxKeys": 100, + "HeartbeatInterval": 86400, + "LocalAuthorizeOffline": false, + "LocalPreAuthorize": false, + "MeterValuesAlignedData": "Energy.Active.Import.Register", + "MeterValuesSampledData": "Energy.Active.Import.Register,SoC", + "MeterValueSampleInterval": 60, + "NumberOfConnectors": 1, + "ResetRetries": 1, + "StopTransactionOnEVSideDisconnect": true, + "StopTransactionOnInvalidId": true, + "StopTxnAlignedData": "Energy.Active.Import.Register", + "StopTxnSampledData": "Energy.Active.Import.Register", + "SupportedFeatureProfiles": "Core,FirmwareManagement,RemoteTrigger,Reservation,LocalAuthListManagement,SmartCharging,CostAndPrice", + "TransactionMessageAttempts": 5, + "TransactionMessageRetryInterval": 1, + "UnlockConnectorOnEVSideDisconnect": true + }, + "FirmwareManagement": { + "SupportedFileTransferProtocols": "FTP" + }, + "Security": { + "CpoName": "Pionix", + "AuthorizationKey": "AABBCCDDEEFFGGHH", + "SecurityProfile": 0, + "AdditionalRootCertificateCheck": true + }, + "LocalAuthListManagement": { + "LocalAuthListEnabled": true, + "LocalAuthListMaxLength": 42, + "SendLocalListMaxLength": 42 + }, + "CostAndPrice": { + "CustomDisplayCostAndPrice": true, + "NumberOfDecimalsForCostValues": 4, + "DefaultPrice": + { + "priceText": "This is the price", + "priceTextOffline": "Show this price text when offline!", + "chargingPrice": + { + "kWhPrice": 3.14, + "hourPrice": 0.42 + } + }, + "DefaultPriceText": + { + "priceTexts": + [ + { + "priceText": "This is the price", + "priceTextOffline": "Show this price text when offline!", + "language": "en" + }, + { + "priceText": "Dit is de prijs", + "priceTextOffline": "Laat dit zien wanneer de charging station offline is!", + "language": "nl" + }, + { + "priceText": "Dette er prisen", + "priceTextOffline": "Vis denne pristeksten når du er frakoblet", + "language": "nb_NO" + } + ] + }, + "TimeOffset": "00:00", + "NextTimeOffsetTransitionDateTime": "2024-01-01T00:00:00", + "TimeOffsetNextTransition": "01:00", + "CustomIdleFeeAfterStop": false, + "SupportedLanguages": "en, nl, de, nb_NO", + "CustomMultiLanguageMessages": true, + "Language": "en" + }, + "Reservation": { + "ReserveConnectorZeroSupported": true + } +} diff --git a/tests/ocpp_tests/test_sets/everest_test_utils_probe_modules.py b/tests/ocpp_tests/test_sets/everest_test_utils_probe_modules.py new file mode 100644 index 000000000..bdb265bb6 --- /dev/null +++ b/tests/ocpp_tests/test_sets/everest_test_utils_probe_modules.py @@ -0,0 +1,75 @@ +import pytest +import pytest_asyncio + +from copy import deepcopy +from typing import Dict, List + +from everest.testing.ocpp_utils.central_system import CentralSystem +from everest.testing.core_utils.probe_module import ProbeModule +from everest.testing.core_utils import EverestConfigAdjustmentStrategy + + +@pytest.fixture +def probe_module(started_test_controller, everest_core) -> ProbeModule: + # initiate the probe module, connecting to the same runtime session the test controller started + module = ProbeModule(everest_core.get_runtime_session()) + + return module + + +@pytest_asyncio.fixture +async def chargepoint_with_pm(central_system: CentralSystem, probe_module: ProbeModule): + """Fixture for ChargePoint201. Requires central_system_v201 + """ + # wait for libocpp to go online + cp = await central_system.wait_for_chargepoint() + yield cp + await cp.stop() + + +class ProbeModuleCostAndPriceMetervaluesConfigurationAdjustment(EverestConfigAdjustmentStrategy): + """ + Probe module to be able to 'inject' metervalues + """ + def __init__(self, evse_manager_ids: List[str]): + self.evse_manager_ids = evse_manager_ids + + def adjust_everest_configuration(self, everest_config: Dict): + adjusted_config = deepcopy(everest_config) + + adjusted_config["active_modules"]["grid_connection_point"]["connections"]["powermeter"] = [ + {"module_id": "probe", "implementation_id": "ProbeModulePowerMeter"}] + + for evse_manager_id in self.evse_manager_ids: + adjusted_config["active_modules"][evse_manager_id]["connections"]["powermeter_grid_side"] = [ + {"module_id": "probe", "implementation_id": "ProbeModulePowerMeter"}] + + return adjusted_config + + +class ProbeModuleCostAndPriceDisplayMessageConfigurationAdjustment(EverestConfigAdjustmentStrategy): + """ + Probe module to be able to mock display messages + """ + + def adjust_everest_configuration(self, everest_config: Dict): + adjusted_config = deepcopy(everest_config) + + adjusted_config["active_modules"]["ocpp"]["connections"]["display_message"] = [ + {"module_id": "probe", "implementation_id": "ProbeModuleDisplayMessage"}] + + return adjusted_config + + +class ProbeModuleCostAndPriceSessionCostConfigurationAdjustment(EverestConfigAdjustmentStrategy): + """ + Probe module to be able to mock the session cost interface calls + """ + + def adjust_everest_configuration(self, everest_config: Dict): + adjusted_config = deepcopy(everest_config) + + adjusted_config["active_modules"]["probe"]["connections"]["session_cost"] = [ + {"module_id": "ocpp", "implementation_id": "session_cost"}] + + return adjusted_config diff --git a/tests/ocpp_tests/test_sets/ocpp16/california_pricing_ocpp16.py b/tests/ocpp_tests/test_sets/ocpp16/california_pricing_ocpp16.py new file mode 100644 index 000000000..70436afd3 --- /dev/null +++ b/tests/ocpp_tests/test_sets/ocpp16/california_pricing_ocpp16.py @@ -0,0 +1,953 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Pionix GmbH and Contributors to EVerest + +import asyncio +import logging +import json + +from datetime import datetime, timedelta, timezone + +from unittest.mock import Mock, ANY + + +# fmt: off + +from validations import ( + validate_standard_start_transaction, + validate_standard_stop_transaction +) + +from everest.testing.ocpp_utils.fixtures import * +from everest.testing.ocpp_utils.charge_point_v16 import ChargePoint16 +from everest.testing.ocpp_utils.charge_point_utils import wait_for_and_validate, TestUtility, ValidationMode +from everest.testing.core_utils.controller.test_controller_interface import TestController + +from everest_test_utils import * + +from everest_test_utils_probe_modules import (probe_module, + ProbeModuleCostAndPriceMetervaluesConfigurationAdjustment, + ProbeModuleCostAndPriceDisplayMessageConfigurationAdjustment, + ProbeModuleCostAndPriceSessionCostConfigurationAdjustment) + +# fmt: on + +from ocpp.v16.enums import * +from ocpp.v16 import call, call_result + + +@pytest.mark.asyncio +@pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) +class TestOcpp16CostAndPrice: + """ + Tests for OCPP 1.6 California Pricing Requirements + """ + + # Running cost request data, to be used in tests + running_cost_data = { + "transactionId": 1, + "timestamp": datetime.now(timezone.utc).isoformat(), "meterValue": 1234000, + "cost": 1.345, + "state": "Charging", + "chargingPrice": { + "kWhPrice": 0.123, "hourPrice": 2.00, "flatFee": 42.42}, + "idlePrice": {"graceMinutes": 30, "hourPrice": 1.00}, + "nextPeriod": { + "atTime": (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat(), + "chargingPrice": { + "kWhPrice": 0.100, "hourPrice": 4.00, "flatFee": 84.84}, + "idlePrice": {"hourPrice": 0.50} + }, + "triggerMeterValue": { + "atTime": datetime.now(timezone.utc).isoformat(), + "atEnergykWh": 5.0, + "atPowerkW": 8.0, + "atCPStatus": [ChargePointStatus.finishing, ChargePointStatus.suspended_ev] + } + } + + # Final cost request data, to be used in tests. + final_cost_data = { + "transactionId": 1, + "cost": 3.31, + "priceText": "GBP 2.81 @ 0.12/kWh, GBP 0.50 @ 1/h, TOTAL KWH: 23.4 TIME: 03.50 COST: GBP 3.31. Visit www.cpo.com/invoices/13546 for an invoice of your session.", + "priceTextExtra": [ + {"format": "UTF8", "language": "nl", "content": "€2.81 @ €0.12/kWh, €0.50 @ €1/h, TOTAL KWH: 23.4 " + "TIME: 03.50 COST: €3.31. Bezoek www.cpo.com/invoices/13546 " + "voor een factuur van uw laadsessie."}, + {"format": "UTF8", "language": "de", "content": "€2,81 @ €0,12/kWh, €0,50 @ €1/h, GESAMT-KWH: 23,4 " + "ZEIT: 03:50 KOSTEN: €3,31. Besuchen Sie " + "www.cpo.com/invoices/13546 um eine Rechnung für Ihren " + "Ladevorgang zu erhalten."}], + "qrCodeText": "https://www.cpo.com/invoices/13546" + } + + @staticmethod + async def start_transaction(test_controller: TestController, test_utility: TestUtility, charge_point: ChargePoint16, + test_config: OcppTestConfiguration): + """ + Function to start a transaction during tests. + """ + # Start transaction + await charge_point.change_configuration_req(key="MeterValueSampleInterval", value="300") + + # start charging session + test_controller.plug_in() + + # expect StatusNotification with status preparing + assert await wait_for_and_validate(test_utility, charge_point, "StatusNotification", + call.StatusNotificationPayload(1, ChargePointErrorCode.no_error, + ChargePointStatus.preparing)) + + test_controller.swipe(test_config.authorization_info.valid_id_tag_1) + + test_utility.validation_mode = ValidationMode.STRICT + + # expect authorize.req + assert await wait_for_and_validate(test_utility, charge_point, + "Authorize", + call.AuthorizePayload(test_config.authorization_info.valid_id_tag_1)) + + test_utility.validation_mode = ValidationMode.EASY + + # expect StartTransaction.req + assert await wait_for_and_validate(test_utility, charge_point, "StartTransaction", + call.StartTransactionPayload( + 1, test_config.authorization_info.valid_id_tag_1, 0, ""), + validate_standard_start_transaction) + + # expect StatusNotification with status charging + assert await wait_for_and_validate(test_utility, charge_point, "StatusNotification", + call.StatusNotificationPayload(1, ChargePointErrorCode.no_error, + ChargePointStatus.charging)) + + test_utility.messages.clear() + + @staticmethod + async def await_mock_called(mock): + while not mock.call_count: + await asyncio.sleep(0.1) + + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceDisplayMessageConfigurationAdjustment()) + @pytest.mark.asyncio + async def test_cost_and_price_set_user_price_no_transaction(self, test_config: OcppTestConfiguration, + test_utility: TestUtility, + test_controller: TestController, probe_module, + central_system: CentralSystem): + """ + Test if the datatransfer call returns 'rejected' when session cost is sent while there is no transaction + running. + """ + + logging.info("######### test_cost_and_price_set_user_price_no_transaction #########") + + data = { + "idToken": test_config.authorization_info.valid_id_tag_1, + "priceText": "GBP 0.12/kWh, no idle fee", + "priceTextExtra": [{"format": "UTF8", "language": "nl", + "content": "€0.12/kWh, geen idle fee"}, + {"format": "UTF8", "language": "de", + "content": "€0,12/kWh, keine Leerlaufgebühr"} + ] + } + + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "Accepted" + } + + probe_module.implement_command("ProbeModuleDisplayMessage", "set_display_message", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "get_display_messages", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "clear_display_message", + probe_module_mock_fn) + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + await chargepoint_with_pm.data_transfer_req(vendor_id="org.openchargealliance.costmsg", + message_id="SetUserPrice", + data=json.dumps(data)) + + # No session running, datatransfer should return 'accepted' and id token is added to the message + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.accepted), timeout=5) + + # Display message should have received a message with the current price information + data_received = { + 'request': [{'identifier_id': test_config.authorization_info.valid_id_tag_1, 'identifier_type': 'IdToken', + 'message': {'content': 'GBP 0.12/kWh, no idle fee', 'language': 'en'}}, + {'identifier_id': test_config.authorization_info.valid_id_tag_1, 'identifier_type': 'IdToken', + 'message': {'content': '€0.12/kWh, geen idle fee', 'format': 'UTF8', 'language': 'nl'}}, + {'identifier_id': test_config.authorization_info.valid_id_tag_1, 'identifier_type': 'IdToken', + 'message': {'content': '€0,12/kWh, keine Leerlaufgebühr', 'format': 'UTF8', 'language': 'de'}}] + } + + probe_module_mock_fn.assert_called_once_with(data_received) + + @pytest.mark.asyncio + async def test_cost_and_price_set_user_price_no_transaction_no_id_token(self, test_config: OcppTestConfiguration, + charge_point_v16: ChargePoint16, + test_utility: TestUtility): + """ + Test if the datatransfer call returns 'rejected' when session cost is sent while there is no transaction + running. + """ + + logging.info("######### test_cost_and_price_set_user_price_no_transaction_no_id_token #########") + + data = { + "priceText": "GBP 0.12/kWh, no idle fee", + "priceTextExtra": [{"format": "UTF8", "language": "nl", + "content": "€0.12/kWh, geen idle fee"}, + {"format": "UTF8", "language": "de", + "content": "€0,12/kWh, keine Leerlaufgebühr"} + ] + } + + await charge_point_v16.data_transfer_req(vendor_id="org.openchargealliance.costmsg", message_id="SetUserPrice", + data=json.dumps(data)) + + # No session running, and no id token, datatransfer should return 'rejected' + assert await wait_for_and_validate(test_utility, charge_point_v16, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.rejected), timeout=5) + + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceDisplayMessageConfigurationAdjustment()) + @pytest.mark.asyncio + async def test_cost_and_price_set_user_price_with_transaction(self, test_config: OcppTestConfiguration, + test_utility: TestUtility, + test_controller: TestController, probe_module, + central_system: CentralSystem): + """ + Test if user price is sent correctly when there is a transaction. + """ + + logging.info("######### test_cost_and_price_set_user_price_with_transaction #########") + + data = { + "idToken": test_config.authorization_info.valid_id_tag_1, + "priceText": "GBP 0.12/kWh, no idle fee", + "priceTextExtra": [{"format": "UTF8", "language": "nl", + "content": "€0.12/kWh, geen idle fee"}, + {"format": "UTF8", "language": "de", + "content": "€0,12/kWh, keine Leerlaufgebühr"} + ] + } + + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "Accepted" + } + + probe_module.implement_command("ProbeModuleDisplayMessage", "set_display_message", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "get_display_messages", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "clear_display_message", + probe_module_mock_fn) + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Start transaction + await self.start_transaction(test_controller, test_utility, chargepoint_with_pm, test_config) + + # Send 'set user price', which is tight to a transaction. + await chargepoint_with_pm.data_transfer_req(vendor_id="org.openchargealliance.costmsg", + message_id="SetUserPrice", + data=json.dumps(data)) + + # Datatransfer should be successful. + success = await wait_for_and_validate(test_utility, chargepoint_with_pm, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.accepted), timeout=5) + + # Display message should have received a message with the current price information + data_received = { + 'request': [{'identifier_id': ANY, 'identifier_type': 'SessionId', + 'message': {'content': 'GBP 0.12/kWh, no idle fee', 'language': 'en'}}, + {'identifier_id': ANY, 'identifier_type': 'SessionId', + 'message': {'content': '€0.12/kWh, geen idle fee', 'format': 'UTF8', 'language': 'nl'}}, + {'identifier_id': ANY, 'identifier_type': 'SessionId', + 'message': {'content': '€0,12/kWh, keine Leerlaufgebühr', 'format': 'UTF8', 'language': 'de'}}] + } + + probe_module_mock_fn.assert_called_once_with(data_received) + + assert success + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.asyncio + async def test_cost_and_price_final_cost_no_transaction(self, test_config: OcppTestConfiguration, + charge_point_v16: ChargePoint16, + test_utility: TestUtility): + """ + Test sending of final price when there is no transaction: DataTransfer should return rejected. + """ + logging.info("######### test_cost_and_price_final_cost_no_transaction #########") + + await charge_point_v16.data_transfer_req(vendor_id="org.openchargealliance.costmsg", message_id="FinalCost", + data=json.dumps(self.final_cost_data)) + + # Since there is no transaction, datatransfer should return 'rejected' here. + success = await wait_for_and_validate(test_utility, charge_point_v16, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.rejected), timeout=5) + assert success + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.asyncio + async def test_cost_and_price_final_cost_with_transaction_not_found(self, test_config: OcppTestConfiguration, + charge_point_v16: ChargePoint16, + test_utility: TestUtility, + test_controller: TestController): + """ + A transaction is running when a final cost message is sent, but the transaction is not found. This should + return a 'rejected' response on the DataTransfer message. + """ + logging.info("######### test_cost_and_price_final_cost_with_transaction_not_found #########") + + # Start transaction + await self.start_transaction(test_controller, test_utility, charge_point_v16, test_config) + + data = self.final_cost_data.copy() + # Set a non existing transaction id + data["transactionId"] = 98765 + + await charge_point_v16.data_transfer_req(vendor_id="org.openchargealliance.costmsg", message_id="FinalCost", + data=json.dumps(data)) + + #Transaction does not exist: 'rejected' must be returned. + success = await wait_for_and_validate(test_utility, charge_point_v16, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.rejected), timeout=5) + assert success + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceSessionCostConfigurationAdjustment()) + @pytest.mark.asyncio + async def test_cost_and_price_final_cost_with_transaction(self, test_config: OcppTestConfiguration, + test_utility: TestUtility, + test_controller: TestController, probe_module, + central_system: CentralSystem): + """ + A transaction is running whan a final cost message for that transaction is sent. A session cost message + should be sent now. + """ + logging.info("######### test_cost_and_price_final_cost_with_transaction #########") + + session_cost_mock = Mock() + + probe_module.subscribe_variable("session_cost", "session_cost", session_cost_mock) + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Start transaction + await self.start_transaction(test_controller, test_utility, chargepoint_with_pm, test_config) + + # Send final cost message. + await chargepoint_with_pm.data_transfer_req(vendor_id="org.openchargealliance.costmsg", message_id="FinalCost", + data=json.dumps(self.final_cost_data)) + + # Which is accepted + success = await wait_for_and_validate(test_utility, chargepoint_with_pm, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.accepted), timeout=5) + + received_data = {'cost_chunks': [{'cost': {'value': 33100}}], 'currency': {'decimals': 4}, 'message': [{ + 'content': 'GBP 2.81 @ 0.12/kWh, GBP 0.50 @ 1/h, TOTAL KWH: 23.4 TIME: 03.50 COST: GBP 3.31. ' + 'Visit www.cpo.com/invoices/13546 for an invoice of your session.'}, + { + 'content': '€2.81 @ €0.12/kWh, €0.50 @ €1/h, TOTAL KWH: 23.4 TIME: 03.50 COST: €3.31. ' + 'Bezoek www.cpo.com/invoices/13546 voor een factuur van uw laadsessie.', + 'format': 'UTF8', + 'language': 'nl'}, + { + 'content': '€2,81 @ €0,12/kWh, €0,50 @ €1/h, GESAMT-KWH: 23,4 ZEIT: 03:50 KOSTEN: €3,31. ' + 'Besuchen Sie www.cpo.com/invoices/13546 um eine Rechnung für Ihren Ladevorgang zu erhalten.', + 'format': 'UTF8', + 'language': 'de'}], + 'qr_code': 'https://www.cpo.com/invoices/13546', 'session_id': ANY, 'status': 'Finished'} + + # A session cost message should have been received + await self.await_mock_called(session_cost_mock) + + assert session_cost_mock.call_count == 1 + + # And it should contain the correct data + session_cost_mock.assert_called_once_with(received_data) + + assert success + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceSessionCostConfigurationAdjustment()) + @pytest.mark.asyncio + async def test_cost_and_price_running_cost(self, test_config: OcppTestConfiguration, + test_controller: TestController, + test_utility: TestUtility, probe_module, + central_system: CentralSystem): + """ + A transaction is started and a 'running cost' message with the transaction id is sent. This should send a + session cost message over the interface. + """ + logging.info("######### test_cost_and_price_running_cost #########") + + session_cost_mock = Mock() + probe_module.subscribe_variable("session_cost", "session_cost", session_cost_mock) + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Start transaction + await self.start_transaction(test_controller, test_utility, chargepoint_with_pm, test_config) + + test_utility.messages.clear() + + # Send running cost message. + assert await chargepoint_with_pm.data_transfer_req(vendor_id="org.openchargealliance.costmsg", + message_id="RunningCost", + data=json.dumps(self.running_cost_data)) + + # Since there is a transaction running and the correct transaction id is sent in the running cost request, + # the datatransfer message is accepted. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.accepted)) + + # A session cost call should have been sent now with the correct data. + received_data = { + 'charging_price': [{'category': 'Time', 'price': {'currency': {'decimals': 4}, 'value': {'value': 20000}}}, + {'category': 'Energy', 'price': {'currency': {'decimals': 4}, 'value': {'value': 1230}}}, + {'category': 'FlatFee', + 'price': {'currency': {'decimals': 4}, 'value': {'value': 424200}}}], + 'cost_chunks': [ + {'cost': {'value': 13450}, 'metervalue_to': 1234000, 'timestamp_to': ANY}], + 'currency': {'decimals': 4}, + 'idle_price': {'grace_minutes': 30, 'hour_price': {'currency': {'decimals': 4}, 'value': {'value': 10000}}}, + 'next_period': { + 'charging_price': [{'category': 'Time', + 'price': {'currency': {'decimals': 4}, 'value': {'value': 40000}}}, + {'category': 'Energy', + 'price': {'currency': {'decimals': 4}, 'value': {'value': 1000}}}, + {'category': 'FlatFee', + 'price': {'currency': {'decimals': 4}, 'value': {'value': 848400}}}], + 'idle_price': {'hour_price': {'currency': {'decimals': 4}, 'value': {'value': 5000}}}, + 'timestamp_from': ANY}, + 'session_id': ANY, 'status': 'Running'} + + await self.await_mock_called(session_cost_mock) + + assert session_cost_mock.call_count == 1 + + session_cost_mock.assert_called_once_with(received_data) + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.asyncio + async def test_cost_and_price_running_cost_wrong_transaction(self, test_config: OcppTestConfiguration, + test_controller: TestController, + test_utility: TestUtility, + charge_point_v16: ChargePoint16): + """ + A transaction is started and a running cost message is sent, but the transaction id is not known so the message + is rejected. + """ + logging.info("######### test_cost_and_price_running_cost_wrong_transaction #########") + + # Start transaction + await self.start_transaction(test_controller, test_utility, charge_point_v16, test_config) + + data = self.running_cost_data.copy() + # Set non existing transaction id. + data["transactionId"] = 42 + + # Send running cost message with incorrect transaction id. + assert await charge_point_v16.data_transfer_req(vendor_id="org.openchargealliance.costmsg", + message_id="RunningCost", + data=json.dumps(data)) + + # DataTransfer should return 'rejected' because the transaction is not found. + assert await wait_for_and_validate(test_utility, charge_point_v16, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.rejected), timeout=15) + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.asyncio + async def test_cost_and_price_running_cost_no_transaction(self, test_config: OcppTestConfiguration, + test_utility: TestUtility, + charge_point_v16: ChargePoint16): + """ + There is no transaction but there is a running cost message sent. This should return a 'rejected' on the + DataTransfer request. + """ + logging.info("######### test_cost_and_price_running_cost_no_transaction #########") + + test_utility.messages.clear() + + data = { + "transactionId": 1, + "timestamp": datetime.now(timezone.utc).isoformat(), "meterValue": 1234000, + "cost": 1.345, + "state": "Charging", + "chargingPrice": { + "kWhPrice": 0.123, "hourPrice": 0.00, "flatFee": 0.00}, + "idlePrice": {"graceMinutes": 30, "hourPrice": 1.00}, + "nextPeriod": { + "atTime": (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat(), + "chargingPrice": { + "kWhPrice": 0.100, "hourPrice": 0.00, "flatFee": 0.00}, + "idlePrice": {"hourPrice": 0.00} + }, + "triggerMeterValue": { + "atTime": (datetime.now(timezone.utc) + timedelta(seconds=3)).isoformat(), + "atEnergykWh": 5.0, + "atPowerkW": 8.0, + "atCPStatus": [ChargePointStatus.finishing, ChargePointStatus.available] + } + } + + # Send RunningCost message while there is no transaction. + assert await charge_point_v16.data_transfer_req(vendor_id="org.openchargealliance.costmsg", + message_id="RunningCost", + data=json.dumps(data)) + + # This should return 'Rejected' + assert await wait_for_and_validate(test_utility, charge_point_v16, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.rejected), timeout=15) + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceMetervaluesConfigurationAdjustment( + evse_manager_ids=["evse_manager"])) + @pytest.mark.asyncio + async def test_cost_and_price_running_cost_trigger_time(self, test_config: OcppTestConfiguration, + test_controller: TestController, + test_utility: TestUtility, probe_module, + central_system: CentralSystem): + """ + Send running cost with a trigger time to return meter values. + """ + logging.info("######### test_cost_and_price_running_cost_trigger_time #########") + + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "OK" + } + + probe_module.implement_command("ProbeModulePowerMeter", "start_transaction", probe_module_mock_fn) + probe_module.implement_command("ProbeModulePowerMeter", "stop_transaction", probe_module_mock_fn) + + power_meter_value = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "energy_Wh_import": { + "total": 1.0 + }, + "power_W": { + "total": 1000.0 + } + } + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Start transaction + await self.start_transaction(test_controller, test_utility, chargepoint_with_pm, test_config) + + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + test_utility.messages.clear() + + # Metervalues should be sent at below trigger time. + data = self.running_cost_data.copy() + data["triggerMeterValue"]["atTime"] = (datetime.now(timezone.utc) + timedelta(seconds=3)).isoformat() + + # While the transaction is started, send a 'RunningCost' message. + assert await chargepoint_with_pm.data_transfer_req(vendor_id="org.openchargealliance.costmsg", + message_id="RunningCost", + data=json.dumps(data)) + + # Which is accepted. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.accepted)) + + # At the given time, metervalues must have been sent. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + call.MeterValuesPayload(1, meter_value=[{'sampledValue': [ + {'context': 'Other', 'format': 'Raw', 'location': 'Outlet', + 'measurand': 'Energy.Active.Import.Register', 'unit': 'Wh', + 'value': '1.00'}], 'timestamp': timestamp[:-9] + 'Z'}], + transaction_id=1), timeout=15) + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceMetervaluesConfigurationAdjustment( + evse_manager_ids=["evse_manager"] + )) + @pytest.mark.asyncio + async def test_cost_and_price_running_cost_trigger_energy(self, test_config: OcppTestConfiguration, + test_controller: TestController, + test_utility: TestUtility, probe_module, + central_system: CentralSystem): + """ + Send running cost with a trigger kwh value to return meter values. + """ + logging.info("######### test_cost_and_price_running_cost_trigger_energy #########") + + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "OK" + } + + probe_module.implement_command("ProbeModulePowerMeter", "start_transaction", probe_module_mock_fn) + probe_module.implement_command("ProbeModulePowerMeter", "stop_transaction", probe_module_mock_fn) + + power_meter_value = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "energy_Wh_import": { + "total": 1.0 + } + } + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Start transaction + await self.start_transaction(test_controller, test_utility, chargepoint_with_pm, test_config) + + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + test_utility.messages.clear() + + # Send running cost, which has a trigger specified on atEnergykWh = 5.0 + assert await chargepoint_with_pm.data_transfer_req(vendor_id="org.openchargealliance.costmsg", + message_id="RunningCost", + data=json.dumps(self.running_cost_data)) + + # Datatransfer is valid and should be accepted. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.accepted)) + + # Now increase power meter value so it is above the specified trigger and publish the powermeter value + power_meter_value["energy_Wh_import"]["total"] = 6000.0 + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + # Metervalues should now be sent + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + call.MeterValuesPayload(1, meter_value=[{'sampledValue': [ + {'context': 'Other', 'format': 'Raw', 'location': 'Outlet', + 'measurand': 'Energy.Active.Import.Register', 'unit': 'Wh', + 'value': '6000.00'}], 'timestamp': timestamp[:-9] + 'Z'}], + transaction_id=1)) + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceMetervaluesConfigurationAdjustment( + evse_manager_ids=["evse_manager"] + )) + @pytest.mark.asyncio + async def test_cost_and_price_running_cost_trigger_power(self, test_config: OcppTestConfiguration, + test_controller: TestController, + test_utility: TestUtility, probe_module, + central_system: CentralSystem): + """ + Send running cost with a trigger kw value to return meter values. + """ + logging.info("######### test_cost_and_price_running_cost_trigger_power #########") + + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "OK" + } + + probe_module.implement_command("ProbeModulePowerMeter", "start_transaction", probe_module_mock_fn) + probe_module.implement_command("ProbeModulePowerMeter", "stop_transaction", probe_module_mock_fn) + + power_meter_value = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "energy_Wh_import": { + "total": 1.0 + }, + "power_W": { + "total": 1000.0 + } + } + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Start transaction + await self.start_transaction(test_controller, test_utility, chargepoint_with_pm, test_config) + + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + test_utility.messages.clear() + + # Send running cost data with a trigger specified of 8 kW + assert await chargepoint_with_pm.data_transfer_req(vendor_id="org.openchargealliance.costmsg", + message_id="RunningCost", + data=json.dumps(self.running_cost_data)) + + # DataTransfer message is valid, expect it's accepted. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.accepted)) + + # Set W above the trigger value and publish a new powermeter value. + power_meter_value["energy_Wh_import"]["total"] = 1.0 + power_meter_value["power_W"]["total"] = 10000.0 + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + # Powermeter value should be sent because of the trigger. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + call.MeterValuesPayload(1, meter_value=[{'sampledValue': [ + {'context': 'Other', 'format': 'Raw', 'location': 'Outlet', + 'measurand': 'Energy.Active.Import.Register', 'unit': 'Wh', + 'value': '1.00'}, + {'context': 'Other', 'format': 'Raw', 'location': 'Outlet', + 'measurand': 'Power.Active.Import', 'unit': 'W', + 'value': '10000.00'}], 'timestamp': timestamp[:-9] + 'Z'}], + transaction_id=1)) + + # W value is below trigger, but hysteresis prevents sending the metervalue. + power_meter_value["energy_Wh_import"]["total"] = 8000.0 + power_meter_value["power_W"]["total"] = 7990.0 + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + # So no metervalue is sent. + assert not await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + call.MeterValuesPayload(1, meter_value=[{'sampledValue': [ + {'context': 'Other', 'format': 'Raw', 'location': 'Outlet', + 'measurand': 'Energy.Active.Import.Register', 'unit': 'Wh', + 'value': '8000.00'}, + {'context': 'Other', 'format': 'Raw', 'location': 'Outlet', + 'measurand': 'Power.Active.Import', 'unit': 'W', + 'value': '7990.00'}], 'timestamp': timestamp[:-9] + 'Z'}], + transaction_id=1)) + + # Only when trigger is high ( / low) enough, metervalue will be sent. + power_meter_value["energy_Wh_import"]["total"] = 9500.0 + power_meter_value["power_W"]["total"] = 7200.0 + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + call.MeterValuesPayload(1, meter_value=[{'sampledValue': [ + {'context': 'Other', 'format': 'Raw', 'location': 'Outlet', + 'measurand': 'Energy.Active.Import.Register', 'unit': 'Wh', + 'value': '9500.00'}, + {'context': 'Other', 'format': 'Raw', 'location': 'Outlet', + 'measurand': 'Power.Active.Import', 'unit': 'W', + 'value': '7200.00'}], 'timestamp': timestamp[:-9] + 'Z'}], + transaction_id=1)) + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceMetervaluesConfigurationAdjustment( + evse_manager_ids=["evse_manager"] + )) + @pytest.mark.asyncio + async def test_cost_and_price_running_cost_trigger_cp_status(self, test_config: OcppTestConfiguration, + test_controller: TestController, + test_utility: TestUtility, probe_module, + central_system: CentralSystem): + """ + Send running cost with a trigger chargepoint status to return meter values. + """ + logging.info("######### test_cost_and_price_running_cost_trigger_cp_status #########") + + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "OK" + } + + probe_module.implement_command("ProbeModulePowerMeter", "start_transaction", probe_module_mock_fn) + probe_module.implement_command("ProbeModulePowerMeter", "stop_transaction", probe_module_mock_fn) + + power_meter_value = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "energy_Wh_import": { + "total": 1.0 + }, + "power_W": { + "total": 1000.0 + } + } + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Start transaction + await self.start_transaction(test_controller, test_utility, chargepoint_with_pm, test_config) + + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + test_utility.messages.clear() + + # Send data with cp status finishing and suspended ev as triggers. + data = { + "transactionId": 1, + "timestamp": datetime.now(timezone.utc).isoformat(), "meterValue": 1234000, + "cost": 1.345, + "state": "Charging", + "chargingPrice": { + "kWhPrice": 0.123, "hourPrice": 0.00, "flatFee": 0.00}, + "idlePrice": {"graceMinutes": 30, "hourPrice": 1.00}, + "nextPeriod": { + "atTime": (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat(), + "chargingPrice": { + "kWhPrice": 0.100, "hourPrice": 0.00, "flatFee": 0.00}, + "idlePrice": {"hourPrice": 0.00} + }, + "triggerMeterValue": { + "atCPStatus": [ChargePointStatus.finishing, ChargePointStatus.suspended_ev] + } + } + + assert await chargepoint_with_pm.data_transfer_req(vendor_id="org.openchargealliance.costmsg", + message_id="RunningCost", + data=json.dumps(data)) + + # And wait for the datatransfer to be accepted. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "DataTransfer", + call_result.DataTransferPayload(DataTransferStatus.accepted), timeout=15) + + # swipe id tag to finish transaction + test_controller.swipe(test_config.authorization_info.valid_id_tag_1) + + # expect StatusNotification with status finishing + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "StatusNotification", + call.StatusNotificationPayload(1, ChargePointErrorCode.no_error, + ChargePointStatus.finishing)) + + # As the chargepoint status is now 'finishing' new metervalues should be sent. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + call.MeterValuesPayload(1, meter_value=[{'sampledValue': [ + {'context': 'Other', 'format': 'Raw', 'location': 'Outlet', + 'measurand': 'Energy.Active.Import.Register', 'unit': 'Wh', + 'value': '1.00'}], 'timestamp': timestamp[:-9] + 'Z'}], + transaction_id=1)) + + # expect StopTransaction.req + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "StopTransaction", + call.StopTransactionPayload(0, "", 1, Reason.local), + validate_standard_stop_transaction) + + test_controller.plug_out() + + # # expect StatusNotification.req with status available + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "StatusNotification", + call.StatusNotificationPayload(1, ChargePointErrorCode.no_error, + ChargePointStatus.available)) + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.asyncio + async def test_cost_and_price_set_price_text(self, test_config: OcppTestConfiguration, + charge_point_v16: ChargePoint16, + test_utility: TestUtility): + """ + Test 'DefaultPriceText' configuration setting. + """ + logging.info("######### test_cost_and_price_set_price_text #########") + + test_utility.validation_mode = ValidationMode.STRICT + + # First get price text for specific language. + response = await charge_point_v16.get_configuration_req(key=['DefaultPriceText,de']) + assert response.configuration_key[0]['key'] == 'DefaultPriceText,de' + assert response.configuration_key[0]['value'] == '' + + # Set price text for specific language. + price_text = { + "priceText": "€0.15 / kWh, Leerlaufgebühr nach dem Aufladen: 1 $/hr", + "priceTextOffline": "Die Station ist offline. Laden ist für €0,15/kWh möglich" + } + + response = await charge_point_v16.change_configuration_req(key="DefaultPriceText,de", value=json.dumps(price_text)) + assert response.status == "Accepted" + + # Get price text for specific language to check if it is set. + response = await charge_point_v16.get_configuration_req(key=['DefaultPriceText,de']) + + assert response.configuration_key[0]['key'] == 'DefaultPriceText,de' + assert json.loads(response.configuration_key[0]['value']) == price_text + + # Set price text for not supported language. + price_text = { + "priceText": "0,15 € / kWh, frais d'inactivité après recharge : 1 $/h" + } + response = await charge_point_v16.change_configuration_req(key="DefaultPriceText,fr", value=json.dumps(price_text)) + assert response.status == "Rejected" + + # Get price text for specific language to check if it is set. + response = await charge_point_v16.get_configuration_req(key=['DefaultPriceText,fr']) + + assert response.configuration_key[0]['key'] == 'DefaultPriceText,fr' + assert response.configuration_key[0]['value'] == '' + + @pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp16-costandprice.yaml')) + @pytest.mark.asyncio + async def test_cost_and_price_set_charging_price(self, test_config: OcppTestConfiguration, + charge_point_v16: ChargePoint16, + test_utility: TestUtility): + """ + Test 'DefaultPrice' configuration setting. + """ + logging.info("######### test_cost_and_price_set_charging_price #########") + + test_utility.validation_mode = ValidationMode.STRICT + + # First get price text for specific language. + response = await charge_point_v16.get_configuration_req(key=['DefaultPrice']) + assert response.configuration_key[0]['key'] == 'DefaultPrice' + assert response.configuration_key[0]['value'] + + # Set price text for specific language. + default_price = { + "priceText": "0.15 $/kWh, idle fee after charging: 1 $/hr", + "priceTextOffline": "The station is offline. Charging is possible for 0.15 $/kWh.", + "chargingPrice": {"kWhPrice": 0.15, "hourPrice": 0.00, "flatFee": 0.00} + } + + await charge_point_v16.change_configuration_req(key="DefaultPrice", value=json.dumps(default_price)) + + # Get price text for specific language to check if it is set. + response = await charge_point_v16.get_configuration_req(key=['DefaultPrice']) + + assert response.configuration_key[0]['key'] == 'DefaultPrice' + assert json.loads(response.configuration_key[0]['value']) == default_price diff --git a/tests/ocpp_tests/test_sets/ocpp16/ocpp_compliance_tests.py b/tests/ocpp_tests/test_sets/ocpp16/ocpp_compliance_tests.py index dcd5273af..a1d29f904 100755 --- a/tests/ocpp_tests/test_sets/ocpp16/ocpp_compliance_tests.py +++ b/tests/ocpp_tests/test_sets/ocpp16/ocpp_compliance_tests.py @@ -1,11 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright Pionix GmbH and Contributors to EVerest -import os -import pytest from datetime import datetime, timedelta import logging -import getpass import asyncio from everest.testing.core_utils.controller.test_controller_interface import ( diff --git a/tests/ocpp_tests/test_sets/ocpp201/california_pricing.py b/tests/ocpp_tests/test_sets/ocpp201/california_pricing.py new file mode 100644 index 000000000..7decd3db9 --- /dev/null +++ b/tests/ocpp_tests/test_sets/ocpp201/california_pricing.py @@ -0,0 +1,563 @@ +from datetime import timezone +from unittest.mock import Mock, ANY + +import logging +from copy import deepcopy + +from everest_test_utils import * # Needs to be before the datatypes below since it overrides the v201 Action enum with the v16 one +from everest.testing.ocpp_utils.charge_point_utils import wait_for_and_validate +from everest.testing.ocpp_utils.charge_point_v201 import ChargePoint201 +from everest.testing.core_utils.controller.test_controller_interface import TestController + +from ocpp.v201 import call as call201 +from ocpp.v201 import call_result as call_result201 +from ocpp.v201.enums import (IdTokenType as IdTokenTypeEnum, ConnectorStatusType, + ClearCacheStatusType) +from ocpp.v201.datatypes import * +from everest.testing.ocpp_utils.fixtures import * +from everest_test_utils_probe_modules import (probe_module, + ProbeModuleCostAndPriceMetervaluesConfigurationAdjustment, + ProbeModuleCostAndPriceSessionCostConfigurationAdjustment) + +from everest.testing.core_utils._configuration.libocpp_configuration_helper import ( + GenericOCPP201ConfigAdjustment, + OCPP201ConfigVariableIdentifier, +) + +from validations import validate_status_notification_201 + +log = logging.getLogger("ocpp201CaliforniaPricingTest") + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +@pytest.mark.inject_csms_mock +@pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp201-costandprice.yaml')) +@pytest.mark.ocpp_config_adaptions(GenericOCPP201ConfigAdjustment([ + (OCPP201ConfigVariableIdentifier("DisplayMessageCtrlr", "DisplayMessageCtrlrAvailable", "Actual"), + "true"), + (OCPP201ConfigVariableIdentifier("DisplayMessageCtrlr", "QRCodeDisplayCapable", + "Actual"), "true"), + (OCPP201ConfigVariableIdentifier("DisplayMessageCtrlr", "DisplayMessageLanguage", "Actual"), + "en"), + (OCPP201ConfigVariableIdentifier("TariffCostCtrlr", "TariffCostCtrlrAvailableTariff", "Actual"), + "true"), + (OCPP201ConfigVariableIdentifier("TariffCostCtrlr", "TariffCostCtrlrAvailableCost", "Actual"), + "true"), + (OCPP201ConfigVariableIdentifier("TariffCostCtrlr", "TariffCostCtrlrEnabledTariff", "Actual"), "true"), + (OCPP201ConfigVariableIdentifier("TariffCostCtrlr", "TariffCostCtrlrEnabledCost", "Actual"), "true"), + (OCPP201ConfigVariableIdentifier("TariffCostCtrlr", "NumberOfDecimalsForCostValues", "Actual"), + "5"), + (OCPP201ConfigVariableIdentifier("OCPPCommCtrlr", "MessageTimeout", "Actual"), + "1"), + (OCPP201ConfigVariableIdentifier("OCPPCommCtrlr", "MessageAttemptInterval", + "Actual"), "1"), + (OCPP201ConfigVariableIdentifier("OCPPCommCtrlr", "MessageAttempts", "Actual"), + "3"), + (OCPP201ConfigVariableIdentifier("AuthCacheCtrlr", "AuthCacheCtrlrEnabled", "Actual"), + "true"), + (OCPP201ConfigVariableIdentifier("AuthCtrlr", "LocalPreAuthorize", + "Actual"), "true"), + (OCPP201ConfigVariableIdentifier("AuthCacheCtrlr", "AuthCacheLifeTime", "Actual"), + "86400"), + (OCPP201ConfigVariableIdentifier("CustomizationCtrlr", "CustomImplementationCaliforniaPricingEnabled", + "Actual"), "true"), + (OCPP201ConfigVariableIdentifier("CustomizationCtrlr", "CustomImplementationMultiLanguageEnabled", + "Actual"), "true") +])) +class TestOcpp201CostAndPrice: + """ + Tests for OCPP 2.0.1 California Pricing Requirements + """ + + cost_updated_custom_data = { + "vendorId": "org.openchargealliance.costmsg", + "timestamp": datetime.now(timezone.utc).isoformat(), "meterValue": 1234000, + "state": "Charging", + "chargingPrice": {"kWhPrice": 0.123, "hourPrice": 2.00, "flatFee": 42.42}, + "idlePrice": {"graceMinutes": 30, "hourPrice": 1.00}, + "nextPeriod": { + "atTime": (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat(), + "chargingPrice": {"kWhPrice": 0.100, "hourPrice": 4.00, "flatFee": 84.84}, + "idlePrice": {"hourPrice": 0.50} + }, + "triggerMeterValue": { + "atTime": datetime.now(timezone.utc).isoformat(), + "atEnergykWh": 5.0, + "atPowerkW": 8.0 + } + } + + @staticmethod + async def start_transaction(test_controller: TestController, test_utility: TestUtility, + charge_point: ChargePoint201): + # prepare data for the test + evse_id1 = 1 + connector_id = 1 + + # make an unknown IdToken + id_token = IdTokenType( + id_token="DEADBEEF", + type=IdTokenTypeEnum.iso14443 + ) + + assert await wait_for_and_validate(test_utility, charge_point, "StatusNotification", + call201.StatusNotificationPayload(datetime.now().isoformat(), + ConnectorStatusType.available, + evse_id=evse_id1, + connector_id=connector_id), + validate_status_notification_201) + + # Charging station is now available, start charging session. + # swipe id tag to authorize + test_controller.swipe(id_token.id_token) + assert await wait_for_and_validate(test_utility, charge_point, "Authorize", + call201.AuthorizePayload(id_token + )) + + # start charging session + test_controller.plug_in() + + # should send a Transaction event + transaction_event = await wait_for_and_validate(test_utility, charge_point, "TransactionEvent", + {"eventType": "Started"}) + transaction_id = transaction_event['transaction_info']['transaction_id'] + + assert await wait_for_and_validate(test_utility, charge_point, "TransactionEvent", + {"eventType": "Updated"}) + + return transaction_id + + @staticmethod + async def await_mock_called(mock): + while not mock.call_count: + await asyncio.sleep(0.1) + + @pytest.mark.asyncio + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceSessionCostConfigurationAdjustment()) + async def test_set_running_cost(self, central_system: CentralSystem, test_controller: TestController, + test_utility: TestUtility, test_config: OcppTestConfiguration, probe_module): + """ + Test running and final cost, that is 'embedded' in the TransactionEventResponse. + """ + # prepare data for the test + transaction_event_response_started = call_result201.TransactionEventPayload() + + transaction_event_response = call_result201.TransactionEventPayload() + transaction_event_response.total_cost = 3.13 # According to the OCPP spec this should be a floating point number but the test framework does not allow that. + transaction_event_response.updated_personal_message = {"format": "UTF8", "language": "en", + "content": "$2.81 @ $0.12/kWh, $0.50 @ $1/h, TOTAL KWH: 23.4 TIME: 03.50 COST: $3.31. Visit www.cpo.com/invoices/13546 for an invoice of your session."} + transaction_event_response.custom_data = {"vendorId": "org.openchargealliance.org.qrcode", + "qrCodeText": "https://www.cpo.com/invoices/13546"} + + transaction_event_response_ended = deepcopy(transaction_event_response) + transaction_event_response_ended.total_cost = 55.1 + + received_data = {'cost_chunks': [{'cost': {'value': 313000}, 'timestamp_to': ANY}], + 'currency': {'code': 'EUR', 'decimals': 5}, 'message': [{ + 'content': '$2.81 @ $0.12/kWh, $0.50 @ $1/h, TOTAL KWH: 23.4 TIME: 03.50 COST: $3.31. ' + 'Visit www.cpo.com/invoices/13546 for an invoice of your session.', + 'format': 'UTF8', 'language': 'en'}, + ], + 'qr_code': 'https://www.cpo.com/invoices/13546', 'session_id': ANY, 'status': 'Running'} + + evse_id1 = 1 + connector_id = 1 + + probe_module_mock_fn = Mock() + + probe_module.subscribe_variable("session_cost", "session_cost", probe_module_mock_fn) + + probe_module.start() + await probe_module.wait_to_be_ready() + + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Clear cache + r: call_result201.ClearCachePayload = await chargepoint_with_pm.clear_cache_req() + assert r.status == ClearCacheStatusType.accepted + + # make an unknown IdToken + id_token = IdTokenType( + id_token="DEADBEEF", + type=IdTokenTypeEnum.iso14443 + ) + + # Three TransactionEvents will be sent: started, updated and ended. The last two have the pricing information. + central_system.mock.on_transaction_event.side_effect = [transaction_event_response_started, # Started + transaction_event_response, # Updated + transaction_event_response_ended] # Ended + + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "StatusNotification", + call201.StatusNotificationPayload(datetime.now().isoformat(), + ConnectorStatusType.available, + evse_id=evse_id1, + connector_id=connector_id), + validate_status_notification_201) + + # Charging station is now available, start charging session. + # swipe id tag to authorize + test_controller.swipe(id_token.id_token) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "Authorize", + call201.AuthorizePayload(id_token + )) + + # start charging session + test_controller.plug_in() + + # should send a Transaction event + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "TransactionEvent", + {"eventType": "Started"}) + + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "TransactionEvent", + {"eventType": "Updated"}) + + # A session cost message should have been received + await self.await_mock_called(probe_module_mock_fn) + probe_module_mock_fn.assert_called_once_with(received_data) + + # Now stop the transaction, this should also send a TransactionEvent (Ended) + test_controller.plug_out() + + # 'Final' costs are a bit different than the 'Running' costs. + received_data['cost_chunks'][0] = {'cost': {'value': 5510000}, 'metervalue_to': 0, 'timestamp_to': ANY} + received_data['status'] = 'Finished' + probe_module_mock_fn.call_count = 0 + + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "TransactionEvent", + {"eventType": "Ended"}) + + await self.await_mock_called(probe_module_mock_fn) + probe_module_mock_fn.assert_called_once_with(received_data) + + @pytest.mark.asyncio + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceSessionCostConfigurationAdjustment()) + async def test_cost_updated_request(self, central_system: CentralSystem, + test_controller: TestController, test_utility: TestUtility, + test_config: OcppTestConfiguration, probe_module): + """ + Test the 'cost updated request' with california pricing information. + """ + received_data = { + 'charging_price': [ + {'category': 'Time', 'price': {'currency': {'code': 'EUR', 'decimals': 5}, 'value': {'value': 200000}}}, + {'category': 'Energy', + 'price': {'currency': {'code': 'EUR', 'decimals': 5}, 'value': {'value': 12300}}}, + {'category': 'FlatFee', + 'price': {'currency': {'code': 'EUR', 'decimals': 5}, 'value': {'value': 4242000}}}], + 'cost_chunks': [ + {'cost': {'value': 134500}, 'metervalue_to': 1234000, 'timestamp_to': ANY}], + 'currency': {'code': 'EUR', 'decimals': 5}, + 'idle_price': {'grace_minutes': 30, + 'hour_price': {'currency': {'code': 'EUR', 'decimals': 5}, 'value': {'value': 100000}}}, + 'next_period': { + 'charging_price': [{'category': 'Time', + 'price': {'currency': {'code': 'EUR', 'decimals': 5}, 'value': {'value': 400000}}}, + {'category': 'Energy', + 'price': {'currency': {'code': 'EUR', 'decimals': 5}, 'value': {'value': 10000}}}, + {'category': 'FlatFee', + 'price': {'currency': {'code': 'EUR', 'decimals': 5}, + 'value': {'value': 8484000}}}], + 'idle_price': {'hour_price': {'currency': {'code': 'EUR', 'decimals': 5}, 'value': {'value': 50000}}}, + 'timestamp_from': ANY}, + 'session_id': ANY, 'status': 'Running'} + + session_cost_mock = Mock() + probe_module.subscribe_variable("session_cost", "session_cost", session_cost_mock) + + probe_module.start() + await probe_module.wait_to_be_ready() + + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # prepare data for the test + evse_id1 = 1 + connector_id = 1 + + # make an unknown IdToken + id_token = IdTokenType( + id_token="DEADBEEF", + type=IdTokenTypeEnum.iso14443 + ) + + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "StatusNotification", + call201.StatusNotificationPayload(datetime.now().isoformat(), + ConnectorStatusType.available, + evse_id=evse_id1, + connector_id=connector_id), + validate_status_notification_201) + + # Send cost updated request while there is no transaction: This should just forward the request There is nothing + # in the spec that sais what to do here and you can't send a 'rejected'. + await chargepoint_with_pm.cost_update_req(total_cost=1.345, transaction_id="1", + custom_data=self.cost_updated_custom_data) + + # A session cost message should have been received + await self.await_mock_called(session_cost_mock) + session_cost_mock.assert_called_once_with(received_data) + + # swipe id tag to authorize + test_controller.swipe(id_token.id_token) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "Authorize", + call201.AuthorizePayload(id_token + )) + + # start charging session + test_controller.plug_in() + + # should send a Transaction event + transaction_event = await wait_for_and_validate(test_utility, chargepoint_with_pm, "TransactionEvent", + {"eventType": "Started"}) + transaction_id = transaction_event['transaction_info']['transaction_id'] + + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "TransactionEvent", + {"eventType": "Updated"}) + + # Clear cache + r: call_result201.ClearCachePayload = await chargepoint_with_pm.clear_cache_req() + assert r.status == ClearCacheStatusType.accepted + session_cost_mock.call_count = 0 + + await chargepoint_with_pm.cost_update_req(total_cost=1.345, transaction_id=transaction_id, + custom_data=self.cost_updated_custom_data) + + # A session cost message should have been received + await self.await_mock_called(session_cost_mock) + session_cost_mock.assert_called_once_with(received_data) + + # Clear cache + r: call_result201.ClearCachePayload = await chargepoint_with_pm.clear_cache_req() + assert r.status == ClearCacheStatusType.accepted + session_cost_mock.call_count = 0 + + # Set transaction id to a not existing transaction id. + await chargepoint_with_pm.cost_update_req(total_cost=1.345, transaction_id="12345", + custom_data=self.cost_updated_custom_data) + + # A session cost message should still have been received + await self.await_mock_called(session_cost_mock) + session_cost_mock.assert_called_once_with(received_data) + + @pytest.mark.asyncio + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceMetervaluesConfigurationAdjustment( + evse_manager_ids=["connector_1", "connector_2"])) + async def test_running_cost_trigger_time(self, central_system: CentralSystem, + test_controller: TestController, test_utility: TestUtility, + test_config: OcppTestConfiguration, probe_module): + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "OK" + } + + probe_module.implement_command("ProbeModulePowerMeter", "start_transaction", probe_module_mock_fn) + probe_module.implement_command("ProbeModulePowerMeter", "stop_transaction", probe_module_mock_fn) + + power_meter_value = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "energy_Wh_import": { + "total": 1.0 + }, + "power_W": { + "total": 1000.0 + } + } + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Start transaction + transaction_id = await self.start_transaction(test_controller, test_utility, chargepoint_with_pm) + + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + test_utility.messages.clear() + + # Metervalues should be sent at below trigger time. + data = self.cost_updated_custom_data.copy() + data["triggerMeterValue"]["atTime"] = (datetime.now(timezone.utc) + timedelta(seconds=3)).isoformat() + + # Once the transaction is started, send a 'RunningCost' message. + await chargepoint_with_pm.cost_update_req(total_cost=1.345, transaction_id=transaction_id, + custom_data=data) + + # At the given time, metervalues must have been sent. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + {"evseId": 1, "meterValue": [{"sampledValue": [ + {"context": "Other", "location": "Outlet", + "measurand": "Energy.Active.Import.Register", + "unitOfMeasure": {"unit": "Wh"}, "value": 1.0}], + 'timestamp': timestamp[:-9] + 'Z'}]} + ) + + @pytest.mark.asyncio + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceMetervaluesConfigurationAdjustment( + evse_manager_ids=["connector_1", "connector_2"] + )) + async def test_running_cost_trigger_energy(self, central_system: CentralSystem, + test_controller: TestController, test_utility: TestUtility, + test_config: OcppTestConfiguration, probe_module): + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "OK" + } + + probe_module.implement_command("ProbeModulePowerMeter", "start_transaction", probe_module_mock_fn) + probe_module.implement_command("ProbeModulePowerMeter", "stop_transaction", probe_module_mock_fn) + + power_meter_value = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "energy_Wh_import": { + "total": 1.0 + } + } + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Start transaction + transaction_id = await self.start_transaction(test_controller, test_utility, chargepoint_with_pm) + + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + test_utility.messages.clear() + + # Metervalues should be sent at below trigger time. + data = self.cost_updated_custom_data.copy() + + # Send running cost, which has a trigger specified on atEnergykWh = 5.0 + await chargepoint_with_pm.cost_update_req(total_cost=1.345, transaction_id=transaction_id, + custom_data=data) + + # Now increase power meter value so it is above the specified trigger and publish the powermeter value + power_meter_value["energy_Wh_import"]["total"] = 6000.0 + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + # Powermeter value should be sent because of the trigger. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + {"evseId": 1, "meterValue": [{"sampledValue": [ + {"context": "Other", "location": "Outlet", + "measurand": "Energy.Active.Import.Register", + "unitOfMeasure": {"unit": "Wh"}, "value": 6000.0}], + 'timestamp': timestamp[:-9] + 'Z'}]} + ) + + @pytest.mark.asyncio + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceMetervaluesConfigurationAdjustment( + evse_manager_ids=["connector_1", "connector_2"] + )) + async def test_running_cost_trigger_power(self, central_system: CentralSystem, + test_controller: TestController, test_utility: TestUtility, + test_config: OcppTestConfiguration, probe_module): + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "OK" + } + + probe_module.implement_command("ProbeModulePowerMeter", "start_transaction", probe_module_mock_fn) + probe_module.implement_command("ProbeModulePowerMeter", "stop_transaction", probe_module_mock_fn) + + power_meter_value = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "energy_Wh_import": { + "total": 1.0 + }, + "power_W": { + "total": 1000.0 + } + } + + probe_module.start() + await probe_module.wait_to_be_ready() + + # wait for libocpp to go online + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Start transaction + transaction_id = await self.start_transaction(test_controller, test_utility, chargepoint_with_pm) + + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + test_utility.messages.clear() + + # Metervalues should be sent at below trigger time. + data = self.cost_updated_custom_data.copy() + + # Send running cost, which has a trigger specified on atEnergykWh = 5.0 + await chargepoint_with_pm.cost_update_req(total_cost=1.345, transaction_id=transaction_id, + custom_data=data) + + # Set W above the trigger value and publish a new powermeter value. + power_meter_value["energy_Wh_import"]["total"] = 1.0 + power_meter_value["power_W"]["total"] = 10000.0 + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + # Powermeter value should be sent because of the trigger. + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + {"evseId": 1, "meterValue": [{"sampledValue": [ + {"context": "Other", "location": "Outlet", + "measurand": "Energy.Active.Import.Register", + "unitOfMeasure": {"unit": "Wh"}, "value": 1.0}, + {'context': 'Other', 'location': 'Outlet', + 'measurand': 'Power.Active.Import', "unitOfMeasure": {"unit": "W"}, + 'value': 10000.00} + ], + 'timestamp': timestamp[:-9] + 'Z'}]} + ) + + # W value is below trigger, but hysteresis prevents sending the metervalue. + power_meter_value["power_W"]["total"] = 7990.0 + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + # So no metervalue is sent + assert not await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + {"evseId": 1, "meterValue": [{"sampledValue": [ + {"context": "Other", "location": "Outlet", + "measurand": "Energy.Active.Import.Register", + "unitOfMeasure": {"unit": "Wh"}, "value": 1.0}, + {'context': 'Other', 'location': 'Outlet', + 'measurand': 'Power.Active.Import', "unitOfMeasure": {"unit": "W"}, + 'value': 7990.0} + ], + 'timestamp': timestamp[:-9] + 'Z'}]} + ) + + # Only when trigger is high ( / low) enough, metervalue will be sent. + power_meter_value["power_W"]["total"] = 7200.0 + timestamp = datetime.now(timezone.utc).isoformat() + power_meter_value["timestamp"] = timestamp + probe_module.publish_variable("ProbeModulePowerMeter", "powermeter", power_meter_value) + + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "MeterValues", + {"evseId": 1, "meterValue": [{"sampledValue": [ + {"context": "Other", "location": "Outlet", + "measurand": "Energy.Active.Import.Register", + "unitOfMeasure": {"unit": "Wh"}, "value": 1.0}, + {'context': 'Other', 'location': 'Outlet', + 'measurand': 'Power.Active.Import', "unitOfMeasure": {"unit": "W"}, + 'value': 7200.00} + ], + 'timestamp': timestamp[:-9] + 'Z'}]} + ) diff --git a/tests/ocpp_tests/test_sets/ocpp201/display_message.py b/tests/ocpp_tests/test_sets/ocpp201/display_message.py new file mode 100644 index 000000000..42a9331e9 --- /dev/null +++ b/tests/ocpp_tests/test_sets/ocpp201/display_message.py @@ -0,0 +1,391 @@ +from datetime import timezone +from unittest.mock import Mock + +import pytest + +import logging + +from everest.testing.ocpp_utils.central_system import CentralSystem + +from everest_test_utils import * # Needs to be before the datatypes below since it overrides the v201 Action enum with the v16 one +from everest.testing.ocpp_utils.charge_point_utils import wait_for_and_validate, TestUtility +from everest.testing.ocpp_utils.charge_point_v201 import ChargePoint201 +from everest.testing.core_utils.controller.test_controller_interface import TestController + +from everest_test_utils_probe_modules import (probe_module, + ProbeModuleCostAndPriceDisplayMessageConfigurationAdjustment) + +from ocpp.v201 import call as call201 +from ocpp.v201 import call_result as call_result201 +from ocpp.v201.enums import (IdTokenType as IdTokenTypeEnum, ConnectorStatusType) +from ocpp.v201.datatypes import * + +from everest.testing.core_utils._configuration.libocpp_configuration_helper import ( + GenericOCPP201ConfigAdjustment, + OCPP201ConfigVariableIdentifier, +) +from validations import validate_status_notification_201 + +log = logging.getLogger("ocpp201DisplayMessageTest") + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +@pytest.mark.inject_csms_mock +@pytest.mark.everest_core_config(get_everest_config_path_str('everest-config-ocpp201-costandprice.yaml')) +@pytest.mark.ocpp_config_adaptions(GenericOCPP201ConfigAdjustment([ + (OCPP201ConfigVariableIdentifier("DisplayMessageCtrlr", "DisplayMessageCtrlrAvailable", "Actual"), + "true"), + (OCPP201ConfigVariableIdentifier("DisplayMessageCtrlr", "QRCodeDisplayCapable", + "Actual"), "true"), + (OCPP201ConfigVariableIdentifier("DisplayMessageCtrlr", "DisplayMessageLanguage", "Actual"), + "en"), + (OCPP201ConfigVariableIdentifier("TariffCostCtrlr", "TariffCostCtrlrAvailableTariff", "Actual"), + "true"), + (OCPP201ConfigVariableIdentifier("TariffCostCtrlr", "TariffCostCtrlrAvailableCost", "Actual"), + "true"), + (OCPP201ConfigVariableIdentifier("TariffCostCtrlr", "TariffCostCtrlrEnabledTariff", "Actual"), "true"), + (OCPP201ConfigVariableIdentifier("TariffCostCtrlr", "TariffCostCtrlrEnabledCost", "Actual"), "true"), + (OCPP201ConfigVariableIdentifier("TariffCostCtrlr", "NumberOfDecimalsForCostValues", "Actual"), + "5"), + (OCPP201ConfigVariableIdentifier("OCPPCommCtrlr", "MessageTimeout", "Actual"), + "1"), + (OCPP201ConfigVariableIdentifier("OCPPCommCtrlr", "MessageAttemptInterval", + "Actual"), "1"), + (OCPP201ConfigVariableIdentifier("OCPPCommCtrlr", "MessageAttempts", "Actual"), + "3"), + (OCPP201ConfigVariableIdentifier("AuthCacheCtrlr", "AuthCacheCtrlrEnabled", "Actual"), + "true"), + (OCPP201ConfigVariableIdentifier("AuthCtrlr", "LocalPreAuthorize", + "Actual"), "true"), + (OCPP201ConfigVariableIdentifier("AuthCacheCtrlr", "AuthCacheLifeTime", "Actual"), + "86400"), + (OCPP201ConfigVariableIdentifier("DisplayMessageCtrlr", "DisplayMessageSupportedPriorities", + "Actual"), "AlwaysFront,NormalCycle"), + (OCPP201ConfigVariableIdentifier("DisplayMessageCtrlr", "DisplayMessageSupportedFormats", + "Actual"), "ASCII,URI,UTF8"), + (OCPP201ConfigVariableIdentifier("DisplayMessageCtrlr", "DisplayMessageSupportedStates", "Actual"), + "Charging,Faulted,Unavailable") +])) +class TestOcpp201CostAndPrice: + """ + Tests for OCPP 2.0.1 Display Message + """ + + @staticmethod + async def start_transaction(test_controller: TestController, test_utility: TestUtility, + charge_point: ChargePoint201): + # prepare data for the test + evse_id1 = 1 + connector_id = 1 + + # make an unknown IdToken + id_token = IdTokenType( + id_token="DEADBEEF", + type=IdTokenTypeEnum.iso14443 + ) + + assert await wait_for_and_validate(test_utility, charge_point, "StatusNotification", + call201.StatusNotificationPayload(datetime.now().isoformat(), + ConnectorStatusType.available, + evse_id=evse_id1, + connector_id=connector_id), + validate_status_notification_201) + + # Charging station is now available, start charging session. + # swipe id tag to authorize + test_controller.swipe(id_token.id_token) + assert await wait_for_and_validate(test_utility, charge_point, "Authorize", + call201.AuthorizePayload(id_token + )) + + # start charging session + test_controller.plug_in() + + # should send a Transaction event + transaction_event = await wait_for_and_validate(test_utility, charge_point, "TransactionEvent", + {"eventType": "Started"}) + transaction_id = transaction_event['transaction_info']['transaction_id'] + + assert await wait_for_and_validate(test_utility, charge_point, "TransactionEvent", + {"eventType": "Updated"}) + + return transaction_id + + @staticmethod + async def await_mock_called(mock): + while not mock.call_count: + await asyncio.sleep(0.1) + + @pytest.mark.asyncio + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceDisplayMessageConfigurationAdjustment()) + async def test_set_display_message(self, central_system: CentralSystem, test_controller: TestController, + test_utility: TestUtility, test_config: OcppTestConfiguration, probe_module): + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "Accepted" + } + + probe_module.implement_command("ProbeModuleDisplayMessage", "set_display_message", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "get_display_messages", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "clear_display_message", + probe_module_mock_fn) + + probe_module.start() + await probe_module.wait_to_be_ready() + + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + start_time = datetime.now(timezone.utc).isoformat() + end_time = (datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat() + + message = {'id': 1, 'priority': 'NormalCycle', + 'message': {'format': 'UTF8', 'language': 'en', + 'content': 'This is a display message'}, + 'startDateTime': start_time, + 'endDateTime': end_time} + + await chargepoint_with_pm.set_display_message_req(message=message, custom_data=None) + + # Display message should have received a message with the current price information + data_received = { + 'request': [{'id': 1, 'identifier_type': 'TransactionId', + 'message': {'content': 'This is a display message', 'format': 'UTF8', 'language': 'en'}, + 'priority': 'NormalCycle', 'timestamp_from': start_time[:-9] + 'Z', + 'timestamp_to': end_time[:-9] + 'Z'}] + } + + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "SetDisplayMessage", + call_result201.SetDisplayMessagePayload(status='Accepted'), + timeout=5) + probe_module_mock_fn.assert_called_once_with(data_received) + + # Test rejected return value + probe_module_mock_fn.return_value = { + "status": "Rejected" + } + + await chargepoint_with_pm.set_display_message_req(message=message, custom_data=None) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "SetDisplayMessage", + call_result201.SetDisplayMessagePayload(status='Rejected'), + timeout=5) + + probe_module_mock_fn.return_value = { + "status": "Accepted" + } + + # Test unsupported priority + message['priority'] = 'InFront' + + await chargepoint_with_pm.set_display_message_req(message=message, custom_data=None) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "SetDisplayMessage", + call_result201.SetDisplayMessagePayload(status='NotSupportedPriority'), + timeout=5) + message['priority'] = 'NormalCycle' + + # Test unsupported message format + message['message']['format'] = 'HTML' + + await chargepoint_with_pm.set_display_message_req(message=message, custom_data=None) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "SetDisplayMessage", + call_result201.SetDisplayMessagePayload(status='NotSupportedMessageFormat'), + timeout=5) + message['message']['format'] = 'UTF8' + + # Test unsupported state + message['state'] = 'Idle' + + await chargepoint_with_pm.set_display_message_req(message=message, custom_data=None) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "SetDisplayMessage", + call_result201.SetDisplayMessagePayload(status='NotSupportedState'), + timeout=5) + + message['state'] = 'Charging' + + # Test unknown transaction + message['transactionId'] = '12345' + + await chargepoint_with_pm.set_display_message_req(message=message, custom_data=None) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "SetDisplayMessage", + call_result201.SetDisplayMessagePayload(status='UnknownTransaction'), + timeout=5) + + @pytest.mark.asyncio + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceDisplayMessageConfigurationAdjustment()) + async def test_set_display_message_with_transaction(self, central_system: CentralSystem, + test_controller: TestController, + test_utility: TestUtility, test_config: OcppTestConfiguration, + probe_module): + probe_module_mock_fn = Mock() + probe_module_mock_fn.return_value = { + "status": "Accepted" + } + + probe_module.implement_command("ProbeModuleDisplayMessage", "set_display_message", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "get_display_messages", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "clear_display_message", + probe_module_mock_fn) + + probe_module.start() + await probe_module.wait_to_be_ready() + + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + transaction_id = await self.start_transaction(test_controller, test_utility, chargepoint_with_pm) + + message = {'transactionId': transaction_id, 'id': 1, 'priority': 'NormalCycle', + 'message': {'format': 'UTF8', 'language': 'en', + 'content': 'This is a display message'}} + + await chargepoint_with_pm.set_display_message_req(message=message, custom_data=None) + + # Display message should have received a message with the current price information + data_received = { + 'request': [{'id': 1, 'identifier_id': transaction_id, 'identifier_type': 'TransactionId', + 'message': {'content': 'This is a display message', 'format': 'UTF8', 'language': 'en'}, + 'priority': 'NormalCycle'}] + } + + probe_module_mock_fn.assert_called_once_with(data_received) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "SetDisplayMessage", + call_result201.SetDisplayMessagePayload(status='Accepted'), + timeout=5) + + # Test unknown transaction + message['transactionId'] = '12345' + + await chargepoint_with_pm.set_display_message_req(message=message, custom_data=None) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "SetDisplayMessage", + call_result201.SetDisplayMessagePayload(status='UnknownTransaction'), + timeout=5) + + @pytest.mark.asyncio + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceDisplayMessageConfigurationAdjustment()) + async def test_get_display_messages(self, central_system: CentralSystem, test_controller: TestController, + test_utility: TestUtility, test_config: OcppTestConfiguration, probe_module): + probe_module_mock_fn = Mock() + + probe_module.implement_command("ProbeModuleDisplayMessage", "set_display_message", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "get_display_messages", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "clear_display_message", + probe_module_mock_fn) + + probe_module.start() + await probe_module.wait_to_be_ready() + + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # No messages should return 'unknown' + probe_module_mock_fn.return_value = { + "messages": [] + } + + await chargepoint_with_pm.get_display_nessages_req(request_id=1) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "GetDisplayMessage", + call_result201.GetDisplayMessagesPayload(status='Unknown'), + timeout=5) + + # At least one message should return 'accepted' + probe_module_mock_fn.return_value = { + "messages": [ + {'id': 1, 'message': {'content': 'This is a display message', 'format': 'UTF8', 'language': 'en'}, + 'priority': 'InFront'} + ] + } + + await chargepoint_with_pm.get_display_nessages_req(id=[1], request_id=1) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "GetDisplayMessage", + call_result201.GetDisplayMessagesPayload(status='Accepted'), + timeout=5) + + assert await \ + wait_for_and_validate(test_utility, chargepoint_with_pm, "NotifyDisplayMessages", + call201.NotifyDisplayMessagesPayload(request_id=1, + message_info=[{"id": 1, + "message": { + "content": "This is a " + "display message", + "format": "UTF8", + "language": "en"}, + "priority": "InFront"}])) + + # Return multiple messages + probe_module_mock_fn.return_value = { + "messages": [ + {'id': 1, 'message': {'content': 'This is a display message', 'format': 'UTF8', 'language': 'en'}, + 'priority': 'InFront'}, + {'id': 2, 'message': {'content': 'This is a display message 2', 'format': 'UTF8', 'language': 'en'}, + 'priority': 'NormalCycle'} + ] + } + + await chargepoint_with_pm.get_display_nessages_req(request_id=1) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "GetDisplayMessage", + call_result201.GetDisplayMessagesPayload(status='Accepted'), + timeout=5) + + assert await \ + wait_for_and_validate(test_utility, chargepoint_with_pm, "NotifyDisplayMessages", + call201.NotifyDisplayMessagesPayload(request_id=1, + message_info=[{"id": 1, + "message": { + "content": "This is a " + "display message", + "format": "UTF8", + "language": "en"}, + "priority": "InFront"}, {"id": 2, + "message": { + "content": "This is a " + "display message 2", + "format": "UTF8", + "language": "en"}, + "priority": "NormalCycle"} + ])) + + @pytest.mark.asyncio + @pytest.mark.probe_module + @pytest.mark.everest_config_adaptions(ProbeModuleCostAndPriceDisplayMessageConfigurationAdjustment()) + async def test_clear_display_messages(self, central_system: CentralSystem, test_controller: TestController, + test_utility: TestUtility, test_config: OcppTestConfiguration, probe_module): + probe_module_mock_fn = Mock() + + probe_module.implement_command("ProbeModuleDisplayMessage", "set_display_message", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "get_display_messages", + probe_module_mock_fn) + probe_module.implement_command("ProbeModuleDisplayMessage", "clear_display_message", + probe_module_mock_fn) + + probe_module.start() + await probe_module.wait_to_be_ready() + + chargepoint_with_pm = await central_system.wait_for_chargepoint() + + # Clear display message is accepted + probe_module_mock_fn.return_value = { + "status": "Accepted" + } + + await chargepoint_with_pm.clear_display_message_req(id=1) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "ClearDisplayMessage", + call_result201.ClearDisplayMessagePayload(status='Accepted'), + timeout=5) + + # Clear display message returns unknown + probe_module_mock_fn.return_value = { + "status": "Unknown" + } + + await chargepoint_with_pm.clear_display_message_req(id=1) + assert await wait_for_and_validate(test_utility, chargepoint_with_pm, "ClearDisplayMessage", + call_result201.ClearDisplayMessagePayload(status='Unknown'), + timeout=5)