Implement Subscription Heartbeat Logic

This implements the subscription heartbeat logic which will send the
message `RedfishServiceFunctional` periodically with the interval of
`HeartbeatIntervalMinutes` specified in subscription property [1][2], if
`SendHeartbeat` is enabled..

Note the heartbeat enablement is per event destination as DMTF specifies
[3] like
```
... This message shall only be sent if specifically requested by an event
destination during the creation of a subscription...
```

This also add `HeartbeatEvent` to supported registry prefixes like
```
curl -k -X GET https://${bmc}/redfish/v1/EventService/
{
  ...
  "RegistryPrefixes": [
    "Base",
    "OpenBMC",
    "TaskEvent",
    "HeartbeatEvent"
  ],
  "ResourceTypes": [
    "Task",
    "Heartbeat"
  ],
```

Tested:

1) A single subscription and heartbeat via Redfish Event Listener

- Create a subscription via Redfish Event Listener
- PATCH `SendHeartbeat=true` and `HeartbeatIntervalMinutes` like

```
curl -k -X PATCH https://${bmc}/redfish/v1/EventService/Subscriptions/${SUBID} \
        -H "Content-Type: application/json" \
        -d '{"SendHeartbeat":true, "HeartbeatIntervalMinutes":1}'
```

- Monitor the Redfish Event Listener and check the following heartbeat
  messages periodically (per HeartbeatIntervalMinutes)

```
response_type: POST
headers: {'Host': '9.3.62.209', 'Content-Length': '230'}

response={
  "@odata.type": "#Event.v1_4_0.Event",
  "Events": [
    {
      "@odata.type": "#Message.v1_1_1.Message",
      "EventId": "HeartbeatId",
      "EventTimestamp": "2024-11-21T12:21:47+00:00",
      "MemberId": "0",
      "Message": "Redfish service is functional.",
      "MessageArgs": [],
      "MessageId": "HeartbeatEvent.1.0.1.RedfishServiceFunctional",
      "MessageSeverity": "OK",
      "OriginOfCondition": "/redfish/v1/EventService/Subscriptions/1521743607",
      "Resolution": "None."
    }
  ],
  "Id": "HeartbeatId",
  "Name": "Event Log"
}
```

- Change `SendHeartbeat` to false and see whether the heartbeat message
  is stopped.

2) Multiple sbscribers with the different heartbeat setups

- create 2 event listeners with 2 different destinations (e.g., port
  8080 and 8081).
- Patch sendheartbeat=true to only one subscriber.
- Check whether the only subscriber that enables `SendHeartbeat` is
  receiving the heartbeat messages.

3) Redfish Service Validator passes

[1] https://github.com/openbmc/bmcweb/blob/02ea923f13de196726ac2f022766a6f80bee1c0a/redfish-core/schema/dmtf/json-schema/EventDestination.v1_15_0.json#L356
[2] https://redfish.dmtf.org/registries/HeartbeatEvent.1.0.1.json
[3] https://github.com/DMTF/Redfish/blob/d9e54fc8393d8930bd42e8b134741f5051a2680f/registries/HeartbeatEvent.1.0.1.json#L14

Change-Id: I8682e05f4459940913ba189f1ed016874e38dd4a
Signed-off-by: Myung Bae <myungbae@us.ibm.com>
diff --git a/redfish-core/include/event_service_manager.hpp b/redfish-core/include/event_service_manager.hpp
index a2b1788..7fac1f9 100644
--- a/redfish-core/include/event_service_manager.hpp
+++ b/redfish-core/include/event_service_manager.hpp
@@ -15,6 +15,7 @@
 */
 #pragma once
 #include "dbus_log_watcher.hpp"
+#include "dbus_singleton.hpp"
 #include "dbus_utility.hpp"
 #include "error_messages.hpp"
 #include "event_log.hpp"
@@ -31,6 +32,7 @@
 #include "utils/time_utils.hpp"
 
 #include <boost/asio/io_context.hpp>
+#include <boost/asio/steady_timer.hpp>
 #include <boost/circular_buffer.hpp>
 #include <boost/container/flat_map.hpp>
 #include <boost/url/format.hpp>
@@ -144,6 +146,12 @@
 
             // Update retry configuration.
             subValue->updateRetryConfig(retryAttempts, retryTimeoutInterval);
+
+            // schedule a heartbeat if sendHeartbeat was set to true
+            if (subValue->userSub->sendHeartbeat)
+            {
+                subValue->scheduleNextHeartbeatEvent();
+            }
         }
     }
 
diff --git a/redfish-core/include/heartbeat_messages.hpp b/redfish-core/include/heartbeat_messages.hpp
new file mode 100644
index 0000000..0d4839b
--- /dev/null
+++ b/redfish-core/include/heartbeat_messages.hpp
@@ -0,0 +1,38 @@
+#pragma once
+#include "registries/heartbeat_event_message_registry.hpp"
+
+#include <nlohmann/json.hpp>
+
+#include <array>
+#include <span>
+#include <string_view>
+
+namespace redfish::messages
+{
+
+inline nlohmann::json
+    getLogHeartbeat(redfish::registries::heartbeat_event::Index name,
+                    std::span<const std::string_view> args)
+{
+    size_t index = static_cast<size_t>(name);
+    if (index >= redfish::registries::heartbeat_event::registry.size())
+    {
+        return {};
+    }
+    return getLogFromRegistry(redfish::registries::heartbeat_event::header,
+                              redfish::registries::heartbeat_event::registry,
+                              index, args);
+}
+
+/**
+ * @brief Formats RedfishServiceFunctional message into JSON
+ * Message body: "Redfish service is functional."
+ *
+ * @returns Message RedfishServiceFunctional formatted to JSON */
+inline nlohmann::json redfishServiceFunctional()
+{
+    return getLogHeartbeat(
+        registries::heartbeat_event::Index::redfishServiceFunctional, {});
+}
+
+} // namespace redfish::messages
diff --git a/redfish-core/include/registries_selector.hpp b/redfish-core/include/registries_selector.hpp
index 48b3cf7..8b9cdf3 100644
--- a/redfish-core/include/registries_selector.hpp
+++ b/redfish-core/include/registries_selector.hpp
@@ -1,5 +1,6 @@
 #pragma once
 #include "registries/base_message_registry.hpp"
+#include "registries/heartbeat_event_message_registry.hpp"
 #include "registries/openbmc_message_registry.hpp"
 #include "registries/task_event_message_registry.hpp"
 
@@ -19,6 +20,10 @@
     {
         return {openbmc::registry};
     }
+    if (heartbeat_event::header.registryPrefix == registryName)
+    {
+        return {heartbeat_event::registry};
+    }
     if (base::header.registryPrefix == registryName)
     {
         return {base::registry};
diff --git a/redfish-core/include/subscription.hpp b/redfish-core/include/subscription.hpp
index 62f7826..d1c3ab8 100644
--- a/redfish-core/include/subscription.hpp
+++ b/redfish-core/include/subscription.hpp
@@ -54,6 +54,12 @@
     void resHandler(const std::shared_ptr<Subscription>& /*unused*/,
                     const crow::Response& res);
 
+    void sendHeartbeatEvent();
+    void scheduleNextHeartbeatEvent();
+    void heartbeatParametersChanged();
+    void onHbTimeout(const std::weak_ptr<Subscription>& weakSelf,
+                     const boost::system::error_code& ec);
+
     bool sendEventToSubscriber(std::string&& msg);
 
     bool sendTestEventLog();
@@ -85,6 +91,7 @@
     crow::sse_socket::Connection* sseConn = nullptr;
 
     std::optional<crow::HttpClient> client;
+    boost::asio::steady_timer hbTimer;
 
   public:
     std::optional<filter_ast::LogicalAnd> filter;
diff --git a/redfish-core/lib/event_service.hpp b/redfish-core/lib/event_service.hpp
index 04ca735..57385a2 100644
--- a/redfish-core/lib/event_service.hpp
+++ b/redfish-core/lib/event_service.hpp
@@ -45,13 +45,13 @@
 
 static constexpr const std::array<const char*, 2> supportedEvtFormatTypes = {
     eventFormatType, metricReportFormatType};
-static constexpr const std::array<const char*, 3> supportedRegPrefixes = {
-    "Base", "OpenBMC", "TaskEvent"};
+static constexpr const std::array<const char*, 4> supportedRegPrefixes = {
+    "Base", "OpenBMC", "TaskEvent", "HeartbeatEvent"};
 static constexpr const std::array<const char*, 3> supportedRetryPolicies = {
     "TerminateAfterRetries", "SuspendRetries", "RetryForever"};
 
-static constexpr const std::array<const char*, 1> supportedResourceTypes = {
-    "Task"};
+static constexpr const std::array<const char*, 2> supportedResourceTypes = {
+    "Task", "Heartbeat"};
 
 inline void requestRoutesEventService(App& app)
 {
@@ -708,6 +708,12 @@
             messages::created(asyncResp->res);
             asyncResp->res.addHeader(
                 "Location", "/redfish/v1/EventService/Subscriptions/" + id);
+
+            // schedule a heartbeat
+            if (subValue->userSub->sendHeartbeat)
+            {
+                subValue->scheduleNextHeartbeatEvent();
+            }
         });
 }
 
@@ -875,6 +881,14 @@
                     subValue->userSub->hbIntervalMinutes = *hbIntervalMinutes;
                 }
 
+                if (hbIntervalMinutes || sendHeartbeat)
+                {
+                    // if Heartbeat interval or send heart were changed, cancel
+                    // the heartbeat timer if running and start a new heartbeat
+                    // if needed
+                    subValue->heartbeatParametersChanged();
+                }
+
                 if (verifyCertificate)
                 {
                     subValue->userSub->verifyCertificate = *verifyCertificate;
diff --git a/redfish-core/lib/message_registries.hpp b/redfish-core/lib/message_registries.hpp
index c41b144..a6c3674 100644
--- a/redfish-core/lib/message_registries.hpp
+++ b/redfish-core/lib/message_registries.hpp
@@ -19,6 +19,7 @@
 #include "query.hpp"
 #include "registries.hpp"
 #include "registries/base_message_registry.hpp"
+#include "registries/heartbeat_event_message_registry.hpp"
 #include "registries/openbmc_message_registry.hpp"
 #include "registries/privilege_registry.hpp"
 #include "registries/resource_event_message_registry.hpp"
@@ -49,17 +50,21 @@
     asyncResp->res.jsonValue["Name"] = "MessageRegistryFile Collection";
     asyncResp->res.jsonValue["Description"] =
         "Collection of MessageRegistryFiles";
-    asyncResp->res.jsonValue["Members@odata.count"] = 5;
 
     nlohmann::json& members = asyncResp->res.jsonValue["Members"];
-    for (const char* memberName : std::to_array(
-             {"Base", "TaskEvent", "ResourceEvent", "OpenBMC", "Telemetry"}))
+
+    static constexpr const auto registryFiles = std::to_array(
+        {"Base", "TaskEvent", "ResourceEvent", "OpenBMC", "Telemetry",
+         "HeartbeatEvent"});
+
+    for (const char* memberName : registryFiles)
     {
         nlohmann::json::object_t member;
         member["@odata.id"] =
             boost::urls::format("/redfish/v1/Registries/{}", memberName);
         members.emplace_back(std::move(member));
     }
+    asyncResp->res.jsonValue["Members@odata.count"] = members.size();
 }
 
 inline void requestRoutesMessageRegistryFileCollection(App& app)
@@ -111,6 +116,11 @@
         header = &registries::telemetry::header;
         url = registries::telemetry::url;
     }
+    else if (registry == "HeartbeatEvent")
+    {
+        header = &registries::heartbeat_event::header;
+        url = registries::heartbeat_event::url;
+    }
     else
     {
         messages::resourceNotFound(asyncResp->res, "MessageRegistryFile",
@@ -209,6 +219,15 @@
             registryEntries.emplace_back(&entry);
         }
     }
+    else if (registry == "HeartbeatEvent")
+    {
+        header = &registries::heartbeat_event::header;
+        for (const registries::MessageEntry& entry :
+             registries::heartbeat_event::registry)
+        {
+            registryEntries.emplace_back(&entry);
+        }
+    }
     else
     {
         messages::resourceNotFound(asyncResp->res, "MessageRegistryFile",
diff --git a/redfish-core/src/subscription.cpp b/redfish-core/src/subscription.cpp
index 0b7a9fd..821077a 100644
--- a/redfish-core/src/subscription.cpp
+++ b/redfish-core/src/subscription.cpp
@@ -15,12 +15,14 @@
 */
 #include "subscription.hpp"
 
+#include "dbus_singleton.hpp"
 #include "event_log.hpp"
 #include "event_logs_object_type.hpp"
 #include "event_matches_filter.hpp"
 #include "event_service_store.hpp"
 #include "filter_expr_executor.hpp"
 #include "generated/enums/log_entry.hpp"
+#include "heartbeat_messages.hpp"
 #include "http_client.hpp"
 #include "http_response.hpp"
 #include "logging.hpp"
@@ -29,7 +31,9 @@
 #include "ssl_key_handler.hpp"
 #include "utils/time_utils.hpp"
 
+#include <boost/asio/error.hpp>
 #include <boost/asio/io_context.hpp>
+#include <boost/asio/steady_timer.hpp>
 #include <boost/beast/http/verb.hpp>
 #include <boost/system/errc.hpp>
 #include <boost/url/format.hpp>
@@ -37,6 +41,7 @@
 #include <nlohmann/json.hpp>
 
 #include <algorithm>
+#include <chrono>
 #include <cstdint>
 #include <cstdlib>
 #include <ctime>
@@ -56,7 +61,7 @@
     std::shared_ptr<persistent_data::UserSubscription> userSubIn,
     const boost::urls::url_view_base& url, boost::asio::io_context& ioc) :
     userSub{std::move(userSubIn)},
-    policy(std::make_shared<crow::ConnectionPolicy>())
+    policy(std::make_shared<crow::ConnectionPolicy>()), hbTimer(ioc)
 {
     userSub->destinationUrl = url;
     client.emplace(ioc, policy);
@@ -66,7 +71,7 @@
 
 Subscription::Subscription(crow::sse_socket::Connection& connIn) :
     userSub{std::make_shared<persistent_data::UserSubscription>()},
-    sseConn(&connIn)
+    sseConn(&connIn), hbTimer(crow::connections::systemBus->get_io_context())
 {}
 
 // callback for subscription sendData
@@ -88,6 +93,7 @@
     }
     if (client->isTerminated())
     {
+        hbTimer.cancel();
         if (deleter)
         {
             BMCWEB_LOG_INFO("Subscription {} is deleted after MaxRetryAttempts",
@@ -97,6 +103,83 @@
     }
 }
 
+void Subscription::sendHeartbeatEvent()
+{
+    // send the heartbeat message
+    nlohmann::json eventMessage = messages::redfishServiceFunctional();
+
+    std::string heartEventId = std::to_string(eventSeqNum);
+    eventMessage["EventId"] = heartEventId;
+    eventMessage["EventTimestamp"] = time_utils::getDateTimeOffsetNow().first;
+    eventMessage["OriginOfCondition"] =
+        std::format("/redfish/v1/EventService/Subscriptions/{}", userSub->id);
+    eventMessage["MemberId"] = "0";
+
+    nlohmann::json::array_t eventRecord;
+    eventRecord.emplace_back(std::move(eventMessage));
+
+    nlohmann::json msgJson;
+    msgJson["@odata.type"] = "#Event.v1_4_0.Event";
+    msgJson["Name"] = "Heartbeat";
+    msgJson["Id"] = heartEventId;
+    msgJson["Events"] = std::move(eventRecord);
+
+    std::string strMsg =
+        msgJson.dump(2, ' ', true, nlohmann::json::error_handler_t::replace);
+    sendEventToSubscriber(std::move(strMsg));
+    eventSeqNum++;
+}
+
+void Subscription::scheduleNextHeartbeatEvent()
+{
+    hbTimer.expires_after(std::chrono::minutes(userSub->hbIntervalMinutes));
+    hbTimer.async_wait(
+        std::bind_front(&Subscription::onHbTimeout, this, weak_from_this()));
+}
+
+void Subscription::heartbeatParametersChanged()
+{
+    hbTimer.cancel();
+
+    if (userSub->sendHeartbeat)
+    {
+        scheduleNextHeartbeatEvent();
+    }
+}
+
+void Subscription::onHbTimeout(const std::weak_ptr<Subscription>& weakSelf,
+                               const boost::system::error_code& ec)
+{
+    if (ec == boost::asio::error::operation_aborted)
+    {
+        BMCWEB_LOG_DEBUG("heartbeat timer async_wait is aborted");
+        return;
+    }
+    if (ec == boost::system::errc::operation_canceled)
+    {
+        BMCWEB_LOG_DEBUG("heartbeat timer async_wait canceled");
+        return;
+    }
+    if (ec)
+    {
+        BMCWEB_LOG_CRITICAL("heartbeat timer async_wait failed: {}", ec);
+        return;
+    }
+
+    std::shared_ptr<Subscription> self = weakSelf.lock();
+    if (!self)
+    {
+        BMCWEB_LOG_CRITICAL("onHbTimeout failed on Subscription");
+        return;
+    }
+
+    // Timer expired.
+    sendHeartbeatEvent();
+
+    // reschedule heartbeat timer
+    scheduleNextHeartbeatEvent();
+}
+
 bool Subscription::sendEventToSubscriber(std::string&& msg)
 {
     persistent_data::EventServiceConfig eventServiceConfig =