From 4180de760d4c9ef1f08043282a31f115987d51ab Mon Sep 17 00:00:00 2001 From: Maaike Zijderveld Date: Mon, 16 Dec 2024 12:59:44 +0100 Subject: [PATCH 1/2] Reservation all connectors reserved (#958) Reservation: When a global / non evse specific / evse connector 0 reservation is made and there are as many reservations as available evse's, the evse's should go to 'reserved'. Also some python tests for reservation are created. Signed-off-by: Maaike Zijderveld, iolar --- dependencies.yaml | 4 +- interfaces/evse_manager.yaml | 5 +- modules/Auth/Auth.cpp | 2 +- modules/Auth/include/AuthHandler.hpp | 6 + modules/Auth/include/ReservationHandler.hpp | 38 + modules/Auth/lib/AuthHandler.cpp | 48 +- modules/Auth/lib/ReservationHandler.cpp | 90 +- modules/Auth/tests/reservation_tests.cpp | 147 +- modules/EvseManager/EvseManager.cpp | 15 +- modules/EvseManager/evse/evse_managerImpl.cpp | 7 +- modules/OCPP201/OCPP201.cpp | 4 +- .../config/everest-config-ocpp201.yaml | 3 + .../test_sets/everest_test_utils.py | 1 + .../test_sets/ocpp16/ocpp_compliance_tests.py | 111 +- .../test_sets/ocpp201/reservations.py | 1400 +++++++++++++++++ types/reservation.yaml | 4 + 16 files changed, 1848 insertions(+), 37 deletions(-) create mode 100644 tests/ocpp_tests/test_sets/ocpp201/reservations.py diff --git a/dependencies.yaml b/dependencies.yaml index c93b37133..a6238168d 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -67,7 +67,7 @@ libevse-security: # OCPP libocpp: git: https://github.com/EVerest/libocpp.git - git_tag: e7a37da3610e4cbf66dfbc58b9aa98fca2aa6cec + git_tag: e52ac969095804144af4896af1984cedaf45a3f8 cmake_condition: "EVEREST_DEPENDENCY_ENABLED_LIBOCPP" # Josev Josev: @@ -86,7 +86,7 @@ ext-mbedtls: # everest-testing and ev-dev-tools everest-utils: git: https://github.com/EVerest/everest-utils.git - git_tag: v0.4.3 + git_tag: v0.4.4 # unit testing gtest: diff --git a/interfaces/evse_manager.yaml b/interfaces/evse_manager.yaml index 784e9f37c..3478d3997 100644 --- a/interfaces/evse_manager.yaml +++ b/interfaces/evse_manager.yaml @@ -49,8 +49,9 @@ cmds: arguments: reservation_id: description: >- - The reservation id (should be added to the TransactionStarted - event) + The reservation id (should be added to the TransactionStarted event). Set this to a negative value if there is + no specific reservation id for this evse but the evse should still move to a Reserved state because of total + global reservations. type: integer result: description: Returns true if the EVSE accepted the reservation, else false. diff --git a/modules/Auth/Auth.cpp b/modules/Auth/Auth.cpp index 2eb60ba6b..c4f90d078 100644 --- a/modules/Auth/Auth.cpp +++ b/modules/Auth/Auth.cpp @@ -105,7 +105,7 @@ void Auth::ready() { [this](const std::optional evse_id, const int32_t reservation_id, const ReservationEndReason reason, const bool send_reservation_update) { // Only call the evse manager to cancel the reservation if it was for a specific evse - if (evse_id.has_value()) { + if (evse_id.has_value() && evse_id.value() > 0) { EVLOG_debug << "Call evse manager to cancel the reservation with evse id " << evse_id.value(); this->r_evse_manager.at(evse_id.value() - 1)->call_cancel_reservation(); } diff --git a/modules/Auth/include/AuthHandler.hpp b/modules/Auth/include/AuthHandler.hpp index 971bec488..bf0856b77 100644 --- a/modules/Auth/include/AuthHandler.hpp +++ b/modules/Auth/include/AuthHandler.hpp @@ -276,6 +276,12 @@ class AuthHandler { const AuthorizationType& type); void submit_event_for_connector(const int32_t evse_id, const int32_t connector_id, const ConnectorEvent connector_event); + /** + * @brief Check reservations: if there are as many reservations as evse's, all should be set to reserved. + * + * This will check the reservation status of the evse's and send the statusses to the evse manager. + */ + void check_evse_reserved_and_send_updates(); }; } // namespace module diff --git a/modules/Auth/include/ReservationHandler.hpp b/modules/Auth/include/ReservationHandler.hpp index b585bde4d..870d066fc 100644 --- a/modules/Auth/include/ReservationHandler.hpp +++ b/modules/Auth/include/ReservationHandler.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -15,6 +16,11 @@ class kvsIntf; namespace module { +struct ReservationEvseStatus { + std::set reserved; + std::set available; +}; + class ReservationHandler { private: // Members /// \brief Map of EVSE's, with EVSE id as key and the EVSE struct as value. @@ -45,6 +51,8 @@ class ReservationHandler { const types::reservation::ReservationEndReason reason, const bool send_reservation_update)> reservation_cancelled_callback; + std::set last_reserved_status; + /// \brief worker for the timers. boost::shared_ptr work; /// \brief io_service for the worker for the timers. @@ -125,6 +133,14 @@ class ReservationHandler { std::pair> cancel_reservation(const int reservation_id, const bool execute_callback, const types::reservation::ReservationEndReason reason); + /// + /// \brief Cancel a reservation. + /// \param evse_id The evse id to cancel the reservation for. + /// \param execute_callback True if the `reservation_cancelled_callback` must be called. + /// \return True if the reservation could be cancelled. + /// + bool cancel_reservation(const uint32_t evse_id, const bool execute_callback); + /// /// \brief Register reservation cancelled callback. /// \param callback The callback that should be called when a reservation is cancelled. @@ -163,6 +179,15 @@ class ReservationHandler { /// bool has_reservation_parent_id(const std::optional evse_id); + /// + /// \brief Check if the number of global reservations match the number of available evse's. + /// \return The new reservation status of the evse's. + /// + /// \note The return value has the new reserved and new available statusses (so the ones that were already reserved + /// are not added to those lists). + /// + ReservationEvseStatus check_number_global_reservations_match_number_available_evses(); + private: // Functions /// /// \brief Check if there is a specific connector type in the vector. @@ -308,6 +333,19 @@ class ReservationHandler { /// void store_reservations(); + /// + /// \brief Get new reserved / available status for evse's and store it. + /// \param currently_available_evses Current available evse's. + /// \param reserved_evses Current reserved evse's. + /// \return A struct with changed reservation statuses compared with the last time this function was called. + /// + /// When an evse is reserved and it was available before, it will be added to the set in the struct (return value). + /// But when an evse is reserved and last time it was already reserved, it is not added. + /// + ReservationEvseStatus + get_evse_global_reserved_status_and_set_new_status(const std::set& currently_available_evses, + const std::set& reserved_evses); + /// /// \brief Helper function to print information about reservations and evses, to find out why a reservation has /// failed. diff --git a/modules/Auth/lib/AuthHandler.cpp b/modules/Auth/lib/AuthHandler.cpp index b876e8fb6..da632f9a8 100644 --- a/modules/Auth/lib/AuthHandler.cpp +++ b/modules/Auth/lib/AuthHandler.cpp @@ -67,6 +67,7 @@ void AuthHandler::init_evse(const int evse_id, const int evse_index, const std:: void AuthHandler::initialize() { this->reservation_handler.load_reservations(); + check_evse_reserved_and_send_updates(); } TokenHandlingResult AuthHandler::on_token(const ProvidedIdToken& provided_token) { @@ -611,7 +612,12 @@ ReservationCheckStatus AuthHandler::handle_reservation_exists(std::string& id_to } bool AuthHandler::call_reserved(const int reservation_id, const std::optional& evse_id) { - return this->reserved_callback(evse_id, reservation_id); + const bool reserved = this->reserved_callback(evse_id, reservation_id); + if (reserved) { + this->check_evse_reserved_and_send_updates(); + } + + return reserved; } void AuthHandler::call_reservation_cancelled(const int32_t reservation_id, @@ -619,7 +625,7 @@ void AuthHandler::call_reservation_cancelled(const int32_t reservation_id, const std::optional& evse_id, const bool send_reservation_update) { std::optional evse_index; if (evse_id.has_value() && evse_id.value() > 0) { - EVLOG_info << "Cancel reservation for evse id" << evse_id.value(); + EVLOG_info << "Cancel reservation for evse id " << evse_id.value(); } this->reservation_cancelled_callback(evse_id, reservation_id, reason, send_reservation_update); @@ -659,6 +665,7 @@ void AuthHandler::handle_session_event(const int evse_id, const SessionEvent& ev std::lock_guard lk(this->timer_mutex); this->evses.at(evse_id)->event_mutex.lock(); const auto event_type = event.event; + bool check_reservations = false; switch (event_type) { case SessionEventEnum::SessionStarted: { @@ -689,6 +696,7 @@ void AuthHandler::handle_session_event(const int evse_id, const SessionEvent& ev this->evses.at(evse_id)->transaction_active = true; this->submit_event_for_connector(evse_id, connector_id, ConnectorEvent::TRANSACTION_STARTED); this->evses.at(evse_id)->timeout_timer.stop(); + check_reservations = true; break; } case SessionEventEnum::TransactionFinished: @@ -704,18 +712,26 @@ void AuthHandler::handle_session_event(const int evse_id, const SessionEvent& ev std::lock_guard lk(this->plug_in_queue_mutex); this->plug_in_queue.remove_if([evse_id](int value) { return value == evse_id; }); } + + check_reservations = true; break; } case SessionEventEnum::Disabled: this->submit_event_for_connector(evse_id, connector_id, ConnectorEvent::DISABLE); + check_reservations = true; break; case SessionEventEnum::Enabled: this->submit_event_for_connector(evse_id, connector_id, ConnectorEvent::ENABLE); + check_reservations = true; break; case SessionEventEnum::ReservationStart: break; - case SessionEventEnum::ReservationEnd: + case SessionEventEnum::ReservationEnd: { + if (reservation_handler.is_evse_reserved(evse_id)) { + reservation_handler.cancel_reservation(evse_id, true); + } break; + } /// explicitly fall through all the SessionEventEnum values we are not handling case SessionEventEnum::Authorized: [[fallthrough]]; @@ -747,6 +763,12 @@ void AuthHandler::handle_session_event(const int evse_id, const SessionEvent& ev break; } this->evses.at(evse_id)->event_mutex.unlock(); + + // When reservation is started or ended, check if the number of reservations match the number of evses and + // send 'reserved' notifications to the evse manager accordingly if needed. + if (check_reservations) { + check_evse_reserved_and_send_updates(); + } } void AuthHandler::set_connection_timeout(const int connection_timeout) { @@ -821,4 +843,24 @@ void AuthHandler::submit_event_for_connector(const int32_t evse_id, const int32_ } } +void AuthHandler::check_evse_reserved_and_send_updates() { + ReservationEvseStatus reservation_status = + this->reservation_handler.check_number_global_reservations_match_number_available_evses(); + for (const auto& available_evse : reservation_status.available) { + EVLOG_debug << "Evse " << available_evse << " is now available"; + this->reservation_cancelled_callback( + available_evse, -1, types::reservation::ReservationEndReason::GlobalReservationRequirementDropped, false); + } + + for (const auto& reserved_evse : reservation_status.reserved) { + EVLOG_debug << "Evse " << reserved_evse << " is now reserved"; + if (this->reserved_callback != nullptr) { + const bool reserved = this->reserved_callback(reserved_evse, -1); + if (!reserved) { + EVLOG_warning << "Could not reserve " << reserved_evse << " for non evse specific reservations"; + } + } + } +} + } // namespace module diff --git a/modules/Auth/lib/ReservationHandler.cpp b/modules/Auth/lib/ReservationHandler.cpp index 12f653779..6e4a10571 100644 --- a/modules/Auth/lib/ReservationHandler.cpp +++ b/modules/Auth/lib/ReservationHandler.cpp @@ -1,7 +1,6 @@ #include #include -#include #include @@ -313,6 +312,19 @@ ReservationHandler::cancel_reservation(const int reservation_id, const bool exec return result; } +bool ReservationHandler::cancel_reservation(const uint32_t evse_id, const bool execute_callback) { + auto it = this->evse_reservations.find(evse_id); + if (it != this->evse_reservations.end()) { + int reservation_id = it->second.reservation_id; + return this + ->cancel_reservation(reservation_id, execute_callback, types::reservation::ReservationEndReason::Cancelled) + .first; + } else { + EVLOG_warning << "Could not cancel reservation with evse id " << evse_id; + return false; + } +} + void ReservationHandler::register_reservation_cancelled_callback( const std::function& evse_id, const int32_t reservation_id, const types::reservation::ReservationEndReason reason, @@ -322,7 +334,7 @@ void ReservationHandler::register_reservation_cancelled_callback( void ReservationHandler::on_reservation_used(const int32_t reservation_id) { const std::pair> cancelled = - this->cancel_reservation(reservation_id, true, types::reservation::ReservationEndReason::UsedToStartCharging); + this->cancel_reservation(reservation_id, false, types::reservation::ReservationEndReason::UsedToStartCharging); if (cancelled.first) { if (cancelled.second.has_value()) { EVLOG_info << "Reservation (" << reservation_id << ") for evse#" << cancelled.second.value() @@ -400,6 +412,51 @@ bool ReservationHandler::has_reservation_parent_id(const std::optional return false; } +ReservationEvseStatus ReservationHandler::check_number_global_reservations_match_number_available_evses() { + std::set available_evses; + std::unique_lock lock(this->evse_mutex); + // Get all evse's that are not reserved or used. + for (const auto& evse : this->evses) { + if (get_evse_connector_state_reservation_result(static_cast(evse.first), this->evse_reservations) == + types::reservation::ReservationResult::Accepted && + get_connector_availability_reservation_result(static_cast(evse.first), + types::evse_manager::ConnectorTypeEnum::Unknown) == + types::reservation::ReservationResult::Accepted) { + available_evses.insert(evse.first); + } + } + + std::unique_lock reservation_lock(this->reservation_mutex); + if (available_evses.size() == this->global_reservations.size()) { + // There are as many evses available as 'global' reservations, so all evse's are reserved. Set all available + // evse's to reserved. + return get_evse_global_reserved_status_and_set_new_status(available_evses, available_evses); + } + + // There are not as many global reservations as available evse's, but we have to check for specific connector types + // as well. + std::set reserved_evses_with_specific_connector_type; + for (const auto& global_reservation : this->global_reservations) { + if (!is_reservation_possible(global_reservation.connector_type, this->global_reservations, + this->evse_reservations)) { + // A new reservation with this type is not possible (so also arrival of an extra car is not), so all evse's + // with this connector type should be set to reserved. + for (const auto& evse : this->evses) { + if (available_evses.find(evse.first) != available_evses.end() && + this->has_evse_connector_type( + evse.second->connectors, + global_reservation.connector_type.value_or(types::evse_manager::ConnectorTypeEnum::Unknown))) { + // This evse is available and has a specific connector type. So it should be set to unavailable. + reserved_evses_with_specific_connector_type.insert(evse.first); + } + } + } + } + + return get_evse_global_reserved_status_and_set_new_status(available_evses, + reserved_evses_with_specific_connector_type); +} + bool ReservationHandler::has_evse_connector_type(const std::vector& evse_connectors, const types::evse_manager::ConnectorTypeEnum connector_type) const { if (connector_type == types::evse_manager::ConnectorTypeEnum::Unknown) { @@ -744,6 +801,35 @@ void ReservationHandler::store_reservations() { } } +ReservationEvseStatus ReservationHandler::get_evse_global_reserved_status_and_set_new_status( + const std::set& currently_available_evses, const std::set& reserved_evses) { + ReservationEvseStatus evse_status_to_send; + std::set new_reserved_evses; + + for (const auto evse_id : reserved_evses) { + if (this->last_reserved_status.find(evse_id) != this->last_reserved_status.end()) { + // Evse was already reserved, don't add it to the new status. + } else { + evse_status_to_send.reserved.insert(evse_id); + } + } + + for (const auto evse_id : currently_available_evses) { + const bool is_reserved = reserved_evses.find(evse_id) != reserved_evses.end(); + const bool was_reserved = this->last_reserved_status.find(evse_id) != this->last_reserved_status.end(); + if (not is_reserved) { + if (was_reserved) { + evse_status_to_send.available.insert(evse_id); + } + } + } + + new_reserved_evses = reserved_evses; + this->last_reserved_status = new_reserved_evses; + + return evse_status_to_send; +} + void ReservationHandler::print_reservations_debug_info(const types::reservation::Reservation& reservation, const std::optional evse_id, const bool reservation_failed) { diff --git a/modules/Auth/tests/reservation_tests.cpp b/modules/Auth/tests/reservation_tests.cpp index 25e9339d0..433ed3408 100644 --- a/modules/Auth/tests/reservation_tests.cpp +++ b/modules/Auth/tests/reservation_tests.cpp @@ -949,14 +949,14 @@ TEST_F(ReservationHandlerTest, reservation_timer) { Reservation reservation = create_reservation(types::evse_manager::ConnectorTypeEnum::cCCS2); reservation.expiry_time = Everest::Date::to_rfc3339(date::utc_clock::now() + std::chrono::seconds(1)); EXPECT_EQ(r.make_reservation(std::nullopt, reservation), ReservationResult::Accepted); - sleep(1); + sleep(2); EXPECT_FALSE(evse_id.has_value()); EXPECT_CALL(reservation_callback_mock, Call(_, 0, ReservationEndReason::Expired, true)) .WillOnce(SaveArg<0>(&evse_id)); reservation.expiry_time = Everest::Date::to_rfc3339(date::utc_clock::now() + std::chrono::seconds(1)); EXPECT_EQ(r.make_reservation(0, reservation), ReservationResult::Accepted); - sleep(1); + sleep(2); ASSERT_TRUE(evse_id.has_value()); EXPECT_EQ(evse_id.value(), 0); } @@ -1153,7 +1153,7 @@ TEST_F(ReservationHandlerTest, on_reservation_used) { r.register_reservation_cancelled_callback(reservation_callback_mock.AsStdFunction()); - EXPECT_CALL(reservation_callback_mock, Call(_, _, _, true)).Times(3); + EXPECT_CALL(reservation_callback_mock, Call(_, _, _, true)).Times(0); add_connector(0, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); add_connector(0, 1, types::evse_manager::ConnectorTypeEnum::cType2, this->evses); @@ -1275,4 +1275,145 @@ TEST_F(ReservationHandlerTest, store_load_reservations_connector_unavailable) { EXPECT_EQ(r.reservation_id_to_reservation_timeout_timer_map.size(), 1); } +TEST_F(ReservationHandlerTest, check_evses_to_reserve_scenario_1) { + add_connector(0, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); + add_connector(0, 1, types::evse_manager::ConnectorTypeEnum::cType2, this->evses); + add_connector(1, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); + add_connector(1, 1, types::evse_manager::ConnectorTypeEnum::cType2, this->evses); + + ReservationEvseStatus s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + EXPECT_TRUE(s.reserved.empty()); + + EXPECT_EQ(r.make_reservation(1, create_reservation(types::evse_manager::ConnectorTypeEnum::cCCS2)), + ReservationResult::Accepted); + + s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + EXPECT_TRUE(s.reserved.empty()); + + EXPECT_EQ(r.make_reservation(std::nullopt, create_reservation(types::evse_manager::ConnectorTypeEnum::cCCS2)), + ReservationResult::Accepted); + + s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + ASSERT_EQ(s.reserved.size(), 1); + EXPECT_EQ(s.reserved.count(0), 1); +} + +TEST_F(ReservationHandlerTest, check_evses_to_reserve_scenario_2) { + add_connector(0, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); + add_connector(0, 1, types::evse_manager::ConnectorTypeEnum::cType2, this->evses); + add_connector(1, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); + add_connector(1, 1, types::evse_manager::ConnectorTypeEnum::cType2, this->evses); + + ReservationEvseStatus s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + EXPECT_TRUE(s.reserved.empty()); + + EXPECT_EQ(r.make_reservation(std::nullopt, create_reservation(types::evse_manager::ConnectorTypeEnum::cCCS2)), + ReservationResult::Accepted); + + s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + EXPECT_TRUE(s.reserved.empty()); + + EXPECT_EQ(r.make_reservation(std::nullopt, create_reservation(types::evse_manager::ConnectorTypeEnum::cCCS2)), + ReservationResult::Accepted); + + s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + ASSERT_EQ(s.reserved.size(), 2); + EXPECT_EQ(s.reserved.count(0), 1); + EXPECT_EQ(s.reserved.count(1), 1); +} + +TEST_F(ReservationHandlerTest, check_evses_to_reserve_scenario_3) { + add_connector(0, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); + add_connector(1, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); + add_connector(1, 1, types::evse_manager::ConnectorTypeEnum::cType2, this->evses); + + ReservationEvseStatus s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + EXPECT_TRUE(s.reserved.empty()); + + EXPECT_EQ(r.make_reservation(std::nullopt, create_reservation(types::evse_manager::ConnectorTypeEnum::cType2)), + ReservationResult::Accepted); + + s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + ASSERT_EQ(s.reserved.size(), 1); + EXPECT_EQ(s.reserved.count(1), 1); +} + +TEST_F(ReservationHandlerTest, check_evses_to_reserve_scenario_4) { + add_connector(0, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); + add_connector(0, 1, types::evse_manager::ConnectorTypeEnum::cType2, this->evses); + add_connector(1, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); + add_connector(1, 1, types::evse_manager::ConnectorTypeEnum::cType2, this->evses); + + this->evses.at(0)->plugged_in = true; + + ReservationEvseStatus s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + EXPECT_TRUE(s.reserved.empty()); + + EXPECT_EQ(r.make_reservation(std::nullopt, create_reservation(types::evse_manager::ConnectorTypeEnum::cType2)), + ReservationResult::Accepted); + + s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + ASSERT_EQ(s.reserved.size(), 1); + EXPECT_EQ(s.reserved.count(1), 1); + + this->evses.at(0)->plugged_in = false; + + s = r.check_number_global_reservations_match_number_available_evses(); + + ASSERT_EQ(s.available.size(), 1); + EXPECT_EQ(s.available.count(1), 1); + EXPECT_TRUE(s.reserved.empty()); +} + +TEST_F(ReservationHandlerTest, check_evses_to_reserve_scenario_5) { + add_connector(0, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); + add_connector(0, 1, types::evse_manager::ConnectorTypeEnum::cType2, this->evses); + add_connector(1, 0, types::evse_manager::ConnectorTypeEnum::cCCS2, this->evses); + add_connector(1, 1, types::evse_manager::ConnectorTypeEnum::cType2, this->evses); + + this->evses.at(1)->connectors.at(0).submit_event(ConnectorEvent::TRANSACTION_STARTED); + + ReservationEvseStatus s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + EXPECT_TRUE(s.reserved.empty()); + + EXPECT_EQ(r.make_reservation(std::nullopt, create_reservation(types::evse_manager::ConnectorTypeEnum::cType2)), + ReservationResult::Accepted); + + s = r.check_number_global_reservations_match_number_available_evses(); + + EXPECT_TRUE(s.available.empty()); + ASSERT_EQ(s.reserved.size(), 1); + EXPECT_EQ(s.reserved.count(0), 1); + + this->evses.at(1)->connectors.at(0).submit_event(ConnectorEvent::SESSION_FINISHED); + + s = r.check_number_global_reservations_match_number_available_evses(); + + ASSERT_EQ(s.available.size(), 1); + EXPECT_EQ(s.available.count(0), 1); + EXPECT_TRUE(s.reserved.empty()); +} + } // namespace module diff --git a/modules/EvseManager/EvseManager.cpp b/modules/EvseManager/EvseManager.cpp index 3a15f2722..b269d19bf 100644 --- a/modules/EvseManager/EvseManager.cpp +++ b/modules/EvseManager/EvseManager.cpp @@ -1270,18 +1270,21 @@ bool EvseManager::reserve(int32_t id, const bool signal_reservation_event) { const bool overwrite_reservation = (this->reservation_id == id); - if (reserved) { - EVLOG_info << "Rejecting reservation because evse is already reserved"; + if (reserved && this->reservation_id != -1) { + EVLOG_info << "Rejecting reservation because evse is already reserved for reservation id " + << this->reservation_id; } // Check if this evse is not already reserved, or overwrite reservation if it is for the same reservation id. - if (not reserved || overwrite_reservation) { + if (not reserved || this->reservation_id == -1 || overwrite_reservation) { EVLOG_debug << "Make the reservation with id " << id; reserved = true; - reservation_id = id; + if (id >= 0) { + this->reservation_id = id; + } // When overwriting the reservation, don't signal. - if (not overwrite_reservation && signal_reservation_event) { + if ((not overwrite_reservation || this->reservation_id == -1) && signal_reservation_event) { // publish event to other modules types::evse_manager::SessionEvent se; se.event = types::evse_manager::SessionEventEnum::ReservationStart; @@ -1303,7 +1306,7 @@ void EvseManager::cancel_reservation(bool signal_event) { if (reserved) { EVLOG_debug << "Reservation cancelled"; reserved = false; - reservation_id = -1; + this->reservation_id = -1; // publish event to other modules if (signal_event) { diff --git a/modules/EvseManager/evse/evse_managerImpl.cpp b/modules/EvseManager/evse/evse_managerImpl.cpp index 4b8f424d8..e1ba78a83 100644 --- a/modules/EvseManager/evse/evse_managerImpl.cpp +++ b/modules/EvseManager/evse/evse_managerImpl.cpp @@ -175,6 +175,9 @@ void evse_managerImpl::ready() { session_started.id_tag = provided_id_token; if (mod->is_reserved()) { session_started.reservation_id = mod->get_reservation_id(); + if (start_reason == types::evse_manager::StartSessionReason::Authorized) { + this->mod->cancel_reservation(true); + } } session_started.logging_path = session_log.startSession( @@ -206,7 +209,7 @@ void evse_managerImpl::ready() { transaction_started.meter_value = mod->get_latest_powermeter_data_billing(); if (mod->is_reserved()) { transaction_started.reservation_id.emplace(mod->get_reservation_id()); - mod->cancel_reservation(false); + mod->cancel_reservation(true); } transaction_started.id_tag = id_token; @@ -292,7 +295,7 @@ void evse_managerImpl::ready() { // Cancel reservation, reservation might be stored when swiping rfid, but timed out, so we should not // set the reservation id here. if (mod->is_reserved()) { - mod->cancel_reservation(false); + mod->cancel_reservation(true); } session_log.stopSession(); mod->telemetry.publish("session", "events", diff --git a/modules/OCPP201/OCPP201.cpp b/modules/OCPP201/OCPP201.cpp index 3e7b03427..0f63b9f65 100644 --- a/modules/OCPP201/OCPP201.cpp +++ b/modules/OCPP201/OCPP201.cpp @@ -1114,7 +1114,9 @@ void OCPP201::process_transaction_started(const int32_t evse_id, const int32_t c auto tx_event = TxEvent::AUTHORIZED; auto trigger_reason = ocpp::v201::TriggerReasonEnum::Authorized; const auto transaction_started = session_event.transaction_started.value(); - transaction_data->reservation_id = transaction_started.reservation_id; + if (transaction_started.reservation_id.has_value()) { + transaction_data->reservation_id = transaction_started.reservation_id; + } transaction_data->remote_start_id = transaction_started.id_tag.request_id; const auto id_token = conversions::to_ocpp_id_token(transaction_started.id_tag.id_token); transaction_data->id_token = id_token; diff --git a/tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp201.yaml b/tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp201.yaml index 193907883..621b028fb 100644 --- a/tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp201.yaml +++ b/tests/ocpp_tests/test_sets/everest-aux/config/everest-config-ocpp201.yaml @@ -125,6 +125,9 @@ active_modules: security: - module_id: evse_security implementation_id: main + reservation: + - module_id: auth + implementation_id: reservation persistent_store: module: PersistentStore config_module: diff --git a/tests/ocpp_tests/test_sets/everest_test_utils.py b/tests/ocpp_tests/test_sets/everest_test_utils.py index 019274f1e..1cdefb848 100644 --- a/tests/ocpp_tests/test_sets/everest_test_utils.py +++ b/tests/ocpp_tests/test_sets/everest_test_utils.py @@ -374,6 +374,7 @@ def get_everest_config(function_name, module_name): "local_authorization_list", "transactions", "meterValues", + "reservations", ]: return Path(__file__).parent / Path( "everest-aux/config/everest-config-ocpp201.yaml" 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 41c041698..dcd5273af 100755 --- a/tests/ocpp_tests/test_sets/ocpp16/ocpp_compliance_tests.py +++ b/tests/ocpp_tests/test_sets/ocpp16/ocpp_compliance_tests.py @@ -4661,30 +4661,107 @@ async def test_reservation_connector_rejected( ) +@pytest.mark.ocpp_config_adaptions( + GenericOCPP16ConfigAdjustment([("Reservation", "ReserveConnectorZeroSupported", False)]) +) @pytest.mark.asyncio -@pytest.mark.skip( - reason="Libocpp currently doesnt support ReserveConnectorZeroSupported" +async def test_reservation_connector_zero_not_supported( + charge_point_v16: ChargePoint16, test_utility: TestUtility, test_config: OcppTestConfiguration +): + logging.info("######### test_reservation_connector_zero_not_supported #########") + + await charge_point_v16.reserve_now_req( + connector_id=0, + expiry_date=(datetime.utcnow() + timedelta(minutes=10)).isoformat(), + id_tag=test_config.authorization_info.valid_id_tag_1, + reservation_id=0, + ) + + # expect ReserveNow.conf with status rejected + assert await wait_for_and_validate( + test_utility, + charge_point_v16, + "ReserveNow", + call_result.ReserveNowPayload(ReservationStatus.rejected), + ) + + +@pytest.mark.ocpp_config_adaptions( + GenericOCPP16ConfigAdjustment([("Reservation", "ReserveConnectorZeroSupported", True)]) ) -async def test_reservation_transaction( - charge_point_v16: ChargePoint16, test_utility: TestUtility +@pytest.mark.asyncio +async def test_reservation_connector_zero_supported( + charge_point_v16: ChargePoint16, + test_utility: TestUtility, + test_config: OcppTestConfiguration, + test_controller: TestController, ): - logging.info("######### test_reservation_transaction #########") + logging.info("######### test_reservation_connector_zero_supported #########") - # FIXME: implement this missing testcase! + await charge_point_v16.reserve_now_req( + connector_id=0, + expiry_date=(datetime.utcnow() + timedelta(minutes=10)).isoformat(), + id_tag=test_config.authorization_info.valid_id_tag_1, + reservation_id=0, + ) - await charge_point_v16.change_configuration_req( - key="ReserveConnectorZeroSupported", value="true" + # expect ReserveNow.conf with status rejected + assert await wait_for_and_validate( + test_utility, + charge_point_v16, + "ReserveNow", + call_result.ReserveNowPayload(ReservationStatus.accepted), ) + + # expect StatusNotification with status reserved assert await wait_for_and_validate( test_utility, charge_point_v16, - "ChangeConfiguration", - call_result.ChangeConfigurationPayload(ConfigurationStatus.accepted), + "StatusNotification", + call.StatusNotificationPayload( + 1, ChargePointErrorCode.no_error, ChargePointStatus.reserved + ), + ) + + # swipe valid id tag to authorize + test_controller.swipe(test_config.authorization_info.valid_id_tag_1) + + # expect StatusNotification with status preparing + assert await wait_for_and_validate( + test_utility, + charge_point_v16, + "StatusNotification", + call.StatusNotificationPayload( + 1, ChargePointErrorCode.no_error, ChargePointStatus.preparing + ), + ) + + # start charging session + test_controller.plug_in() + + # expect StartTransaction.req + assert await wait_for_and_validate( + test_utility, + charge_point_v16, + "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_v16, + "StatusNotification", + call.StatusNotificationPayload( + 1, ChargePointErrorCode.no_error, ChargePointStatus.charging + ), ) @pytest.mark.asyncio -@pytest.mark.skip(reason="EVerest SIL currently does not support faulted state") async def test_reservation_faulted_state( test_config: OcppTestConfiguration, charge_point_v16: ChargePoint16, @@ -4693,7 +4770,9 @@ async def test_reservation_faulted_state( ): logging.info("######### test_reservation_faulted_state #########") - test_controller.diode_fail() + test_controller.raise_error("MREC6UnderVoltage", 1) + + await asyncio.sleep(1) # expect StatusNotification with status faulted assert await wait_for_and_validate( @@ -4701,7 +4780,7 @@ async def test_reservation_faulted_state( charge_point_v16, "StatusNotification", call.StatusNotificationPayload( - 1, ChargePointErrorCode.ground_failure, ChargePointStatus.faulted + 1, ChargePointErrorCode.other_error, ChargePointStatus.faulted ), ) await charge_point_v16.reserve_now_req( @@ -4719,6 +4798,8 @@ async def test_reservation_faulted_state( call_result.ReserveNowPayload(ReservationStatus.faulted), ) + test_controller.clear_error("MREC6UnderVoltage", 1) + @pytest.mark.asyncio async def test_reservation_occupied_state( @@ -4899,13 +4980,13 @@ async def test_reservation_cancel_rejected( @pytest.mark.asyncio -async def test_reservation_with_partentid( +async def test_reservation_with_parentid( test_config: OcppTestConfiguration, charge_point_v16: ChargePoint16, test_utility: TestUtility, test_controller: TestController, ): - logging.info("######### test_reservation_with_partentid #########") + logging.info("######### test_reservation_with_parentid #########") # authorize.conf with parent id tag @on(Action.Authorize) diff --git a/tests/ocpp_tests/test_sets/ocpp201/reservations.py b/tests/ocpp_tests/test_sets/ocpp201/reservations.py new file mode 100644 index 000000000..9a83eddfe --- /dev/null +++ b/tests/ocpp_tests/test_sets/ocpp201/reservations.py @@ -0,0 +1,1400 @@ +import pytest +import logging +from unittest.mock import ANY + +from everest.testing.ocpp_utils.charge_point_v201 import ChargePoint201 +from everest.testing.ocpp_utils.charge_point_utils import wait_for_and_validate, TestUtility +from everest.testing.ocpp_utils.fixtures import * +from everest.testing.core_utils.controller.test_controller_interface import TestController +from everest.testing.core_utils._configuration.libocpp_configuration_helper import GenericOCPP201ConfigAdjustment +from everest_test_utils import * + +from ocpp.v201.enums import (IdTokenType as IdTokenTypeEnum, ReserveNowStatusType, ConnectorStatusType, + OperationalStatusType, CancelReservationStatusType, SetVariableStatusType, + RequestStartStopStatusType) +from ocpp.v201.datatypes import * +from ocpp.v201 import call as call_201 +from ocpp.v201 import call_result as call_result201 +from validations import validate_remote_start_stop_transaction +from ocpp.routing import on, create_route_map + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_local_start_tx( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_controller: TestController, + test_utility: TestUtility, +): + """ + Test making a reservation and start a transaction on the reserved evse id with the correct id token. + """ + logging.info("######### test_reservation_local_start_tx #########") + + t = datetime.utcnow() + timedelta(minutes=10) + + await charge_point_v201.reserve_now_req( + id=0, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # expect ReserveNow response with status accepted + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # expect StatusNotification with status reserved + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.reserved, 1, 1 + ), + ) + + # swipe invalid id tag + test_controller.swipe(test_config.authorization_info.invalid_id_tag) + + # swipe valid id tag to authorize + test_controller.swipe(test_config.authorization_info.valid_id_tag_1) + + # expect StatusNotification with status available (reservation is now used) + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.available, 1, 1 + ), + ) + + # start charging session + test_controller.plug_in() + + # expect TransactionEvent with event type Started and the reservation id. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Started", "reservationId": 0} + ) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Updated"} + ) + + # expect StatusNotification with status occupied + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Occupied', 1, 1 + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_local_start_tx_plugin_first( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_controller: TestController, + test_utility: TestUtility, +): + """ + Test making a reservation and start a transaction on the reserved evse id with the correct id token. + """ + logging.info("######### test_reservation_local_start_tx #########") + + t = datetime.utcnow() + timedelta(minutes=10) + + await charge_point_v201.reserve_now_req( + id=0, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # expect ReserveNow response with status accepted + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # expect StatusNotification with status reserved + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.reserved, 1, 1 + ), + ) + + test_utility.messages.clear() + + # start charging session + test_controller.plug_in() + + await asyncio.sleep(2) + + test_utility.messages.clear() + + # swipe valid id tag that belongs to this reservation to authorize + test_controller.swipe(test_config.authorization_info.valid_id_tag_1) + + # expect StatusNotification with status available (reservation is now used) + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.occupied, 1, 1 + ), + ) + + # expect TransactionEvent with event type Started and the reservation id. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Started", "reservationId": 0} + ) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Updated"} + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_plug_in_other_idtoken( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_controller: TestController, + test_utility: TestUtility, +): + """ + Test making a reservation and start a transaction on the reserved evse id with the wrong id token, plug in first. + """ + logging.info("######### test_reservation_plug_in_other_idtoken #########") + + t = datetime.utcnow() + timedelta(minutes=10) + + await charge_point_v201.reserve_now_req( + id=0, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # expect ReserveNow response with status accepted + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # expect StatusNotification with status reserved + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.reserved, 1, 1 + ), + ) + + test_utility.messages.clear() + + # start charging session + test_controller.plug_in() + + # No StatusNotification with status occupied should be sent + assert not await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.occupied, 1, 1 + ), + timeout=5 + ) + + test_utility.messages.clear() + + # swipe invalid id tag + test_controller.swipe(test_config.authorization_info.valid_id_tag_2) + + assert not await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.occupied, 1, 1 + ), + timeout=5 + ) + + test_utility.messages.clear() + + # swipe valid id tag to authorize + test_controller.swipe(test_config.authorization_info.valid_id_tag_1) + + # expect TransactionEvent with event type Started and the reservation id. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Started", "reservationId": 0} + ) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Updated"} + ) + + # expect StatusNotification with status occupied + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Occupied', 1, 1 + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_remote_start_tx( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_controller: TestController, + test_utility: TestUtility, +): + """ + Test making a reservation and start a remote transaction on the reserved evse id with the correct id token. + """ + logging.info("######### test_reservation_remote_start_tx #########") + + t = datetime.utcnow() + timedelta(minutes=10) + + # Make reservation for evse id 1. + await charge_point_v201.reserve_now_req( + id=0, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # expect ReserveNow response with status accepted + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # expect StatusNotification with status reserved + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.reserved, 1, 1 + ), + ) + + # send start transaction request + await charge_point_v201.request_start_transaction_req( + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), remote_start_id=1, evse_id=1 + ) + + # Which should be accepted + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "RequestStartTransaction", + call_result201.RequestStartTransactionPayload(status="Accepted"), + validate_remote_start_stop_transaction, + ) + + # start charging session + test_controller.plug_in() + + # expect StatusNotification with status available (because reservation is 'used') + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.available, 1, 1 + ), + ) + + # expect StatusNotification with status occupied + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Occupied', 1, 1 + ), + ) + + # expect StartTransaction with the given reservation id + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Started", "reservationId": 0} + ) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Updated"} + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_connector_expire( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_controller: TestController, + test_utility: TestUtility, +): + """ + Test that a reservation can expire. + """ + logging.info("######### test_reservation_connector_expire #########") + + # Make a reservation with an expiry time ten seconds from now. + t = datetime.utcnow() + timedelta(seconds=10) + + await charge_point_v201.reserve_now_req( + id=5, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # Reservation is accepted + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # expect StatusNotification with status reserved + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.reserved, 1, 1 + ), + ) + + # Request to start transaction for the reserved evse but with another id token. + await charge_point_v201.request_start_transaction_req( + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_2, + type=IdTokenTypeEnum.iso14443), remote_start_id=1, evse_id=1 + ) + + # This will not succeed. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "RequestStartTransaction", + call_result201.RequestStartTransactionPayload(status="Rejected"), + validate_remote_start_stop_transaction, + ) + + # So we wait until the reservation is expired. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReservationStatusUpdate", + call_201.ReservationStatusUpdatePayload( + 5, "Expired" + ), + ) + + # expect StatusNotification with status available + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.available, 1, 1 + ), + ) + + # Try to start a transaction now on the previously reserved evse (which reservation is now expired). + await charge_point_v201.request_start_transaction_req( + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_2, + type=IdTokenTypeEnum.iso14443), remote_start_id=1, evse_id=1 + ) + + # Now it succeeds. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "RequestStartTransaction", + call_result201.RequestStartTransactionPayload(status="Accepted"), + validate_remote_start_stop_transaction, + ) + + # Start charging session + test_controller.plug_in() + + # expect StatusNotification with status occupied + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Occupied', 1, 1 + ), + ) + + # expect TransactionEvent with event type 'Started' + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Started"} + ) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Updated"} + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_connector_faulted( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_controller: TestController, + test_utility: TestUtility, +): + """ + Test if an evse can be reserved when the evse status is 'Faulted' + """ + logging.info("######### test_reservation_connector_faulted #########") + + # Set evse in state 'faulted' + test_controller.raise_error("MREC6UnderVoltage", 1) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Faulted', 1, 1 + ), + ) + + await asyncio.sleep(1) + + t = datetime.utcnow() + timedelta(minutes=10) + + # Send a reserve new request + await charge_point_v201.reserve_now_req( + id=0, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # which should return 'faulted' + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.faulted), + ) + + test_controller.clear_error() + + test_utility.messages.clear() + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Available', 1, 1 + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_connector_faulted_after_reservation( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_controller: TestController, + test_utility: TestUtility, +): + """ + Test if a reservation is cancelled after the evse status is 'Faulted' + """ + logging.info("######### test_reservation_connector_faulted_after_reservation #########") + + t = datetime.utcnow() + timedelta(minutes=10) + await charge_point_v201.reserve_now_req( + id=42, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # Set evse in state 'faulted' + test_controller.raise_error("MREC6UnderVoltage", 1) + + # This should result in the reservation being cancelled. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReservationStatusUpdate", + call_201.ReservationStatusUpdatePayload( + 42, "Removed" + ), + ) + + test_controller.clear_error() + + test_utility.messages.clear() + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Available', 1, 1 + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_connector_occupied( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_controller: TestController, + test_utility: TestUtility, +): + """ + Try to make a reservation while a evse is occupied. + """ + logging.info("######### test_reservation_connector_occupied #########") + + # start charging session + test_controller.plug_in() + + # TODO mz fix comments everywhere in this file!!! + # expect StatusNotification with status occupied + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Occupied', 1, 1 + ), + ) + + t = datetime.utcnow() + timedelta(minutes=10) + + await asyncio.sleep(2) + + # Request reservation + await charge_point_v201.reserve_now_req( + id=0, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # expect ReserveNow response with status occupied + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.occupied), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_connector_unavailable( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_utility: TestUtility, +): + """ + Test making a reservation with an unavailable connector, this should return 'unavailable' on a reserve now request. + """ + logging.info("######### test_reservation_connector_unavailable #########") + + # Set evse id 1 to inoperative. + await charge_point_v201.change_availablility_req( + evse={'id': 1}, operational_status=OperationalStatusType.inoperative + ) + + t = datetime.utcnow() + timedelta(seconds=30) + + # Make a reservation for evse id 1. + await charge_point_v201.reserve_now_req( + id=0, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # Which should fail (ReserveNow response 'Unavailable'). + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.unavailable), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +@pytest.mark.ocpp_config_adaptions( + GenericOCPP201ConfigAdjustment( + [ + ( + OCPP201ConfigVariableIdentifier( + "ReservationCtrlr", "ReservationCtrlrAvailable", "Actual" + ), + "false", + ) + ] + ) +) +async def test_reservation_connector_rejected( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_utility: TestUtility, + test_controller: TestController, +): + """ + Test if reservation is rejected with the reservation ctrlr is not available. + """ + logging.info("######### test_reservation_connector_rejected #########") + + t = datetime.utcnow() + timedelta(seconds=10) + + # Try to make reservation. + await charge_point_v201.reserve_now_req( + id=5, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # expect ReserveNow response with status rejected + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.rejected), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +@pytest.mark.ocpp_config_adaptions( + GenericOCPP201ConfigAdjustment( + [ + ( + OCPP201ConfigVariableIdentifier( + "ReservationCtrlr", "ReservationCtrlrNonEvseSpecific", "Actual" + ), + "false", + ) + ] + ) +) +async def test_reservation_non_evse_specific_rejected( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_utility: TestUtility, + test_controller: TestController, +): + """ + Try to make a non-evse specific reservation, while that is not allowed according to the settings. That should fail. + """ + logging.info("######### test_reservation_non_evse_specific_rejected #########") + + t = datetime.utcnow() + timedelta(seconds=30) + + # Try to make a reservation without evse id. + await charge_point_v201.reserve_now_req( + id=5, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat() + ) + + # expect ReserveNow respone with status rejected + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.rejected), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +@pytest.mark.ocpp_config_adaptions( + GenericOCPP201ConfigAdjustment( + [ + ( + OCPP201ConfigVariableIdentifier( + "ReservationCtrlr", "ReservationCtrlrNonEvseSpecific", "Actual" + ), + "true", + ) + ] + ) +) +async def test_reservation_non_evse_specific_accepted( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_utility: TestUtility, + test_controller: TestController, +): + """ + Try to make a non evse specific reservation. This should succeed, according to the settings (devicemodel). + """ + logging.info("######### test_reservation_non_evse_specific_accepted #########") + + t = datetime.utcnow() + timedelta(seconds=30) + + # Make reservation without evse id. + await charge_point_v201.reserve_now_req( + id=5, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat() + ) + + # This should be accepted. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # swipe valid id tag to authorize + test_controller.swipe(test_config.authorization_info.valid_id_tag_1) + + # start charging session + test_controller.plug_in() + + # expect TransactionEvent with event type 'Started' and the correct reservation id. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Started", "reservationId": 5} + ) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Updated"} + ) + + # expect StatusNotification with status occupied + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Occupied', 1, 1 + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +@pytest.mark.ocpp_config_adaptions( + GenericOCPP201ConfigAdjustment( + [ + ( + OCPP201ConfigVariableIdentifier( + "ReservationCtrlr", "ReservationCtrlrNonEvseSpecific", "Actual" + ), + "true", + ) + ] + ) +) +async def test_reservation_non_evse_specific_accepted_multiple( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_utility: TestUtility, + test_controller: TestController, +): + """ + Try to make a non evse specific reservation. This should succeed, according to the settings (devicemodel). + When making multiple reservations, as soon as there are as many reservations as evse's available, the evse's should + go to occupied. + """ + logging.info("######### test_reservation_non_evse_specific_accepted_multiple #########") + + t = datetime.utcnow() + timedelta(seconds=30) + + # Make reservation + await charge_point_v201.reserve_now_req( + id=5, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat() + ) + + # Expect it to be accepted. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # Make another reservation with another reservation id. + await charge_point_v201.reserve_now_req( + id=6, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat() + ) + + # expect This should be accepted as well. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # There are now as many reservations as evse's, so all evse's go to 'reserved'. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.reserved, 1, 1 + ), + ) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.reserved, 2, 1 + ), + ) + + # There are now as many reservations as evse's, so another reservation is not possible. + await charge_point_v201.reserve_now_req( + id=7, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_2, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat() + ) + + # expect ReserveNow response with status occupied + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.occupied), + ) + + # swipe valid id tag to authorize + test_controller.swipe(test_config.authorization_info.valid_id_tag_1) + + # start charging session + test_controller.plug_in() + + # expect TransactionEvent 'Started' with the correct reservation id. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Started", "reservationId": 5} + ) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Updated"} + ) + + # expect StatusNotification with status charging + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Occupied', 1, 1 + ), + ) + + # swipe id tag to de-authorize + test_controller.swipe(test_config.authorization_info.valid_id_tag_1) + + # stop charging session + test_controller.plug_out() + + # expect TransactionEvent 'Ended' with the correct reservation id. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Ended"} + ) + + # expect StatusNotification with status 'available' for both evse's as there are now less reservations than + # available evse's. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Available', 1, 1 + ), + ) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Available', 2, 1 + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_faulted_state( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_utility: TestUtility, + test_controller: TestController, +): + """ + Test if making a reservation is possible if the evse is in faulted state. + """ + logging.info("######### test_reservation_faulted_state #########") + + test_controller.raise_error("MREC6UnderVoltage", 1) + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Faulted', 1, 1 + ), + ) + + await asyncio.sleep(1) + + t = datetime.utcnow() + timedelta(seconds=30) + + # Try to make the reservation. + await charge_point_v201.reserve_now_req( + id=0, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # expect ReserveNow response with status 'Faulted' + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.faulted), + ) + + test_controller.clear_error("MREC6UnderVoltage", 1) + + test_utility.messages.clear() + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Available', 1, 1 + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_cancel( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_controller: TestController, + test_utility: TestUtility, +): + """ + Test if a reservation can be cancelled. + """ + logging.info("######### test_reservation_cancel #########") + + t = datetime.utcnow() + timedelta(minutes=10) + + # Make the reservation + await charge_point_v201.reserve_now_req( + id=5, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # Expect it to be accepted. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # expect StatusNotification request with status reserved + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.reserved, 1, 1 + ), + ) + + # Cancel the reservation. + await charge_point_v201.cancel_reservation_req(reservation_id=5) + + # expect CancelReservation response with status accepted + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "CancelReservation", + call_result201.CancelReservationPayload(status=CancelReservationStatusType.accepted), + ) + + # expect StatusNotification with status available + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.available, 1, 1 + ), + ) + + # start charging session + test_controller.plug_in() + + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, 'Occupied', 1, 1 + ), + ) + + # send request start transaction + await charge_point_v201.request_start_transaction_req( + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), remote_start_id=1, evse_id=1 + ) + + # Which should be accepted + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "RequestStartTransaction", + call_result201.RequestStartTransactionPayload(status="Accepted"), + validate_remote_start_stop_transaction, + ) + + # expect TransactionEvent with eventType 'Started' + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Started"} + ) + + # expect TransactionEvent with status 'Updated' + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Updated"} + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_cancel_rejected( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_utility: TestUtility, +): + """ + Try to cancel a non existing reservation + """ + logging.info("######### test_reservation_cancel_rejected #########") + + t = datetime.utcnow() + timedelta(minutes=10) + + # Make a reservation with reservation id 5 + await charge_point_v201.reserve_now_req( + id=5, + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + expiry_date_time=t.isoformat(), + evse_id=1 + ) + + # expect ReserveNow response with status accepted + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # expect StatusNotification with status reserved + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.reserved, 1, 1 + ), + ) + + # Try to cancel reservation with reservation id 2, which does not exist. + await charge_point_v201.cancel_reservation_req(reservation_id=2) + + # expect CancelReservation response with status rejected + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "CancelReservation", + call_result201.CancelReservationPayload(status=CancelReservationStatusType.rejected), + ) + + +@pytest.mark.asyncio +@pytest.mark.ocpp_version("ocpp2.0.1") +async def test_reservation_with_parentid( + test_config: OcppTestConfiguration, + charge_point_v201: ChargePoint201, + test_utility: TestUtility, + test_controller: TestController, + central_system_v201: CentralSystem +): + """ + Test reservation with parent id. + """ + logging.info("######### test_reservation_with_parentid #########") + + # authorize.conf with parent id tag + @on(Action.Authorize) + def on_authorize(**kwargs): + id_tag_info = IdTokenInfoType( + status=AuthorizationStatusType.accepted, + group_id_token=IdTokenType( + id_token=test_config.authorization_info.parent_id_tag, type=IdTokenTypeEnum.iso14443 + ), + ) + return call_result201.AuthorizePayload(id_token_info=id_tag_info) + + setattr(charge_point_v201, "on_authorize", on_authorize) + central_system_v201.chargepoint.route_map = create_route_map( + central_system_v201.chargepoint + ) + charge_point_v201.route_map = create_route_map(charge_point_v201) + + r: call_result201.SetVariablesPayload = ( + await charge_point_v201.set_config_variables_req( + "AuthCtrlr", "AuthorizeRemoteStart", "true" + ) + ) + + set_variable_result: SetVariableResultType = SetVariableResultType( + **r.set_variable_result[0] + ) + assert set_variable_result.attribute_status == SetVariableStatusType.accepted + + # Disable remote authorization so an 'Authorize' request is sent when starting remotely. + r: call_result201.SetVariablesPayload = ( + await charge_point_v201.set_config_variables_req( + "AuthCtrlr", "DisableRemoteAuthorization", "false" + ) + ) + + set_variable_result: SetVariableResultType = SetVariableResultType( + **r.set_variable_result[0] + ) + assert set_variable_result.attribute_status == SetVariableStatusType.accepted + + t = datetime.utcnow() + timedelta(minutes=10) + + # Make a new reservation. + await charge_point_v201.reserve_now_req( + evse_id=1, + expiry_date_time=t.isoformat(), + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_1, + type=IdTokenTypeEnum.iso14443), + group_id_token=IdTokenType(id_token=test_config.authorization_info.parent_id_tag, + type=IdTokenTypeEnum.iso14443), + id=0, + ) + + # expect ReserveNow response with status accepted + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "ReserveNow", + call_result201.ReserveNowPayload(ReserveNowStatusType.accepted), + ) + + # expect StatusNotification request with status reserved + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "StatusNotification", + call_201.StatusNotificationPayload( + ANY, ConnectorStatusType.reserved, 1, 1 + ), + ) + + # start charging session + test_controller.plug_in() + + # send request start transaction for another id tag than the one from the reservation. + await charge_point_v201.request_start_transaction_req( + id_token=IdTokenType(id_token=test_config.authorization_info.valid_id_tag_2, + type=IdTokenTypeEnum.iso14443), remote_start_id=1, evse_id=1 + ) + + # This is accepted because the reservation has a group id token. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "RequestStartTransaction", + call_result201.RequestStartTransactionPayload(status="Accepted"), + validate_remote_start_stop_transaction, + ) + + # expect Authorize request. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "Authorize", + call_201.AuthorizePayload(IdTokenType(id_token=test_config.authorization_info.valid_id_tag_2, + type=IdTokenTypeEnum.iso14443)), + ) + + # Authorize was accepted because of the correct group id token, transaction is started. + r: call_201.TransactionEventPayload = call_201.TransactionEventPayload( + **await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Started"} + ) + ) + + transaction = TransactionType(**r.transaction_info) + + # expect TransactionEvent with eventType 'Updated' + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Updated"} + ) + + # send request stop transaction + await charge_point_v201.request_stop_transaction_req( + transaction_id=transaction.transaction_id + ) + + # Which should be accepted. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "RequestStopTransaction", + call_result201.RequestStartTransactionPayload( + status=RequestStartStopStatusType.accepted + ), + ) + + # And the session should end. + assert await wait_for_and_validate( + test_utility, + charge_point_v201, + "TransactionEvent", + {"eventType": "Ended"} + ) diff --git a/types/reservation.yaml b/types/reservation.yaml index 7de755266..168a27997 100644 --- a/types/reservation.yaml +++ b/types/reservation.yaml @@ -54,11 +54,15 @@ types: Expired: When the reservation expired before the reserved token was used for a session Cancelled: When the reservation was cancelled manually UsedToStartCharging: When the reservation ended because the reserved token was used for a session + GlobalReservationRequirementDropped: When the reservation ended for that specific EVSE because there is a + connector free and there are less reservations than available evse's + (reservation is still there but it is not occupying this EVSE anymore). type: string enum: - Expired - Cancelled - UsedToStartCharging + - GlobalReservationRequirementDropped ReservationEnd: description: Details on Reservation End type: object From a9d1ab08754a661027cbbded37cf399d424f42b8 Mon Sep 17 00:00:00 2001 From: Maaike Zijderveld Date: Mon, 16 Dec 2024 18:12:09 +0100 Subject: [PATCH 2/2] 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)