Implement cable monitor

Cable monitor consumes a cable configuration file which mentions which
cables are expected to be connected. This configuration file would be
staged by an external service running in the DC. Cable monitor also
consumes the Cable inventory from EM and compares that against the
expected cables from configuration file. Based on this, it generates
cable connected/disconnected events. For more information, refer to
design https://gerrit.openbmc.org/c/openbmc/docs/+/74799.

Related PRs:
https://gerrit.openbmc.org/c/openbmc/phosphor-dbus-interfaces/+/74397

Tested:
Testing has been performed with simulated configuration.
```
>> busctl tree xyz.openbmc_project.EntityManager
`- /xyz
  `- /xyz/openbmc_project
    |- /xyz/openbmc_project/EntityManager
    `- /xyz/openbmc_project/inventory
      `- /xyz/openbmc_project/inventory/system
        `- /xyz/openbmc_project/inventory/system/cable
          |- /xyz/openbmc_project/inventory/system/cable/Yosemite_4_Tray_Cable
          | |- /xyz/openbmc_project/inventory/system/cable/Yosemite_4_Tray_Cable/TrayDetector1
          | `- /xyz/openbmc_project/inventory/system/cable/Yosemite_4_Tray_Cable/TrayDetector2
          `- /xyz/openbmc_project/inventory/system/cable/Yosemite_4_Tray_Cable_2
            |- /xyz/openbmc_project/inventory/system/cable/Yosemite_4_Tray_Cable_2/TrayDetector3
            `- /xyz/openbmc_project/inventory/system/cable/Yosemite_4_Tray_Cable_2/TrayDetector4

>> cat /var/lib/cablemonitor.d/cable-config.json
{
  "ConnectedCables": ["Yosemite 4 Tray Cable", "Yosemite 4 Tray Cable 2"]
}

>> busctl tree xyz.openbmc_project.EntityManager
`- /xyz
  `- /xyz/openbmc_project
    |- /xyz/openbmc_project/EntityManager
    `- /xyz/openbmc_project/inventory
      `- /xyz/openbmc_project/inventory/system
        `- /xyz/openbmc_project/inventory/system/cable
          `- /xyz/openbmc_project/inventory/system/cable/Yosemite_4_Tray_Cable
            |- /xyz/openbmc_project/inventory/system/cable/Yosemite_4_Tray_Cable/TrayDetector1
            `- /xyz/openbmc_project/inventory/system/cable/Yosemite_4_Tray_Cable/TrayDetector2

>> curl -k -H "X-Auth-Token: $token" -X GET https://${bmc}/redfish/v1/Systems/system/LogServices/EventLog/Entries
{
  "@odata.id": "/redfish/v1/Systems/system/LogServices/EventLog/Entries",
  "@odata.type": "#LogEntryCollection.LogEntryCollection",
  "Description": "Collection of System Event Log Entries",
  "Members": [
	...
    {
      "@odata.id": "/redfish/v1/Systems/system/LogServices/EventLog/Entries/3",
      "@odata.type": "#LogEntry.v1_9_0.LogEntry",
      "AdditionalDataURI": "/redfish/v1/Systems/system/LogServices/EventLog/Entries/3/attachment",
      "Created": "2024-05-10T08:53:12.409+00:00",
      "EntryType": "Event",
      "Id": "3",
      "Message": "xyz.openbmc_project.State.Cable.CableDisconnected",
      "Modified": "2024-05-10T08:53:12.409+00:00",
      "Name": "System Event Log Entry",
      "Resolved": false,
      "Severity": "Warning"
    }
  ],
  "Members@odata.count": 2,
  "Name": "System Event Log Entries"
}

>> curl -k -H "X-Auth-Token: $token" -X GET https://${bmc}/redfish/v1/Systems/system/LogServices/EventLog/Entries
{
  "@odata.id": "/redfish/v1/Systems/system/LogServices/EventLog/Entries",
  "@odata.type": "#LogEntryCollection.LogEntryCollection",
  "Description": "Collection of System Event Log Entries",
  "Members": [
	...
    {
      "@odata.id": "/redfish/v1/Systems/system/LogServices/EventLog/Entries/3",
      "@odata.type": "#LogEntry.v1_9_0.LogEntry",
      "AdditionalDataURI": "/redfish/v1/Systems/system/LogServices/EventLog/Entries/3/attachment",
      "Created": "2024-05-10T08:53:12.409+00:00",
      "EntryType": "Event",
      "Id": "3",
      "Message": "xyz.openbmc_project.State.Cable.CableDisconnected",
      "Modified": "2024-05-10T08:54:13.691+00:00",
      "Name": "System Event Log Entry",
      "Resolved": true,
      "Severity": "Warning"
    },
    {
      "@odata.id": "/redfish/v1/Systems/system/LogServices/EventLog/Entries/5",
      "@odata.type": "#LogEntry.v1_9_0.LogEntry",
      "AdditionalDataURI": "/redfish/v1/Systems/system/LogServices/EventLog/Entries/5/attachment",
      "Created": "2024-05-10T08:54:13.743+00:00",
      "EntryType": "Event",
      "Id": "5",
      "Message": "xyz.openbmc_project.State.Cable.CableConnected",
      "Modified": "2024-05-10T08:54:13.743+00:00",
      "Name": "System Event Log Entry",
      "Resolved": false,
      "Severity": "OK"
    }
  ],
  "Members@odata.count": 4,
  "Name": "System Event Log Entries"
}
```

Change-Id: Ic0552962406a95cc46d4dd1d83d72e68e0bd28b4
Signed-off-by: Jagpal Singh Gill <paligill@gmail.com>
diff --git a/meson.options b/meson.options
index 00e6fa9..c8a8d34 100644
--- a/meson.options
+++ b/meson.options
@@ -101,3 +101,9 @@
     value: 'enabled',
     description: 'Enable SMBPBI sensor.',
 )
+option(
+    'cable-monitor',
+    type: 'feature',
+    value: 'enabled',
+    description: 'Enable Cable monitor.',
+)
diff --git a/service_files/meson.build b/service_files/meson.build
index 5a9938d..baac30f 100644
--- a/service_files/meson.build
+++ b/service_files/meson.build
@@ -6,6 +6,7 @@
 
 unit_files = [
     ['adc', 'xyz.openbmc_project.adcsensor.service'],
+    ['cable-monitor', 'xyz.openbmc_project.cablemonitor.service'],
     ['exit-air', 'xyz.openbmc_project.exitairsensor.service'],
     ['external', 'xyz.openbmc_project.externalsensor.service'],
     ['fan', 'xyz.openbmc_project.fansensor.service'],
diff --git a/service_files/xyz.openbmc_project.cablemonitor.service b/service_files/xyz.openbmc_project.cablemonitor.service
new file mode 100644
index 0000000..b7f931a
--- /dev/null
+++ b/service_files/xyz.openbmc_project.cablemonitor.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Cable Monitor for Cable Detection
+Requires=xyz.openbmc_project.EntityManager.service
+After=xyz.openbmc_project.EntityManager.service
+
+[Service]
+Restart=always
+RestartSec=5
+ExecStart=/usr/libexec/dbus-sensors/cablemonitor
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/NotifyWatch.cpp b/src/NotifyWatch.cpp
new file mode 100644
index 0000000..d4d8faf
--- /dev/null
+++ b/src/NotifyWatch.cpp
@@ -0,0 +1,118 @@
+#include "NotifyWatch.hpp"
+
+#include <sys/inotify.h>
+#include <unistd.h>
+
+#include <sdbusplus/async.hpp>
+
+#include <array>
+#include <cerrno>
+#include <cstdint>
+#include <cstring>
+#include <filesystem>
+#include <memory>
+#include <span>
+#include <string>
+#include <system_error>
+#include <utility>
+
+namespace notify_watch
+{
+
+namespace fs = std::filesystem;
+
+NotifyWatch::NotifyWatch(sdbusplus::async::context& ctx, const std::string& dir,
+                         Callback_t callback) :
+    ctx(ctx), callback(std::move(callback))
+{
+    std::error_code ec = {};
+
+    fs::path dirPath(dir);
+    if (!fs::create_directories(dirPath, ec))
+    {
+        if (ec)
+        {
+            throw std::system_error(ec, "Failed to create directory " + dir);
+        }
+    }
+
+    fd = inotify_init1(IN_NONBLOCK);
+    if (-1 == fd)
+    {
+        throw std::system_error(errno, std::system_category(),
+                                "inotify_init1 failed");
+    }
+
+    wd = inotify_add_watch(fd, dir.c_str(), IN_CLOSE_WRITE);
+    if (-1 == wd)
+    {
+        close(fd);
+        throw std::system_error(errno, std::system_category(),
+                                "inotify_add_watch failed");
+    }
+
+    fdioInstance = std::make_unique<sdbusplus::async::fdio>(ctx, fd);
+    if (!fdioInstance)
+    {
+        throw std::system_error(errno, std::system_category(),
+                                "Failed to create fdio");
+    }
+}
+
+NotifyWatch::~NotifyWatch()
+{
+    if (-1 != fd)
+    {
+        if (-1 != wd)
+        {
+            inotify_rm_watch(fd, wd);
+        }
+        close(fd);
+    }
+}
+
+auto NotifyWatch::readNotifyAsync() -> sdbusplus::async::task<>
+{
+    if (!fdioInstance)
+    {
+        co_return;
+    }
+    co_await fdioInstance->next();
+
+    alignas(inotify_event) std::array<uint8_t, 4096> buffer{};
+
+    auto bytes = read(fd, buffer.data(), buffer.size());
+    if (bytes < 0)
+    {
+        throw std::system_error(errno, std::system_category(),
+                                "Failed to read notify event");
+    }
+
+    for (auto* iter = buffer.data(); iter < buffer.data() + bytes;)
+    {
+        // Bypassed clang tidy warning about reinterpret_cast as cast is being
+        // performed to avoid copying of buffer data.
+        // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
+        std::span<inotify_event> event{reinterpret_cast<inotify_event*>(iter),
+                                       1};
+        if (((event[0].mask & IN_CLOSE_WRITE) != 0U) &&
+            ((event[0].mask & IN_ISDIR) == 0U))
+        {
+            if (callback)
+            {
+                // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
+                std::span<char> name{reinterpret_cast<char*>(event[0].name),
+                                     event[0].len};
+                co_await callback(std::string(name.begin(), name.end()));
+            }
+        }
+        iter += sizeof(inotify_event) + event[0].len;
+    }
+
+    if (!ctx.stop_requested())
+    {
+        ctx.spawn(readNotifyAsync());
+    }
+}
+
+} // namespace notify_watch
diff --git a/src/NotifyWatch.hpp b/src/NotifyWatch.hpp
new file mode 100644
index 0000000..1e1b33e
--- /dev/null
+++ b/src/NotifyWatch.hpp
@@ -0,0 +1,35 @@
+#pragma once
+
+#include <sdbusplus/async.hpp>
+
+#include <cerrno>
+#include <cstring>
+#include <functional>
+#include <memory>
+#include <string>
+
+namespace notify_watch
+{
+
+class NotifyWatch
+{
+  public:
+    using Callback_t = std::function<sdbusplus::async::task<>(std::string)>;
+
+    NotifyWatch() = delete;
+    explicit NotifyWatch(sdbusplus::async::context& ctx, const std::string& dir,
+                         Callback_t callback);
+    ~NotifyWatch();
+
+    /** @brief Asynchronously watch and notify for any changes to dir */
+    auto readNotifyAsync() -> sdbusplus::async::task<>;
+
+  private:
+    sdbusplus::async::context& ctx;
+    Callback_t callback;
+    int wd = -1;
+    int fd = -1;
+    std::unique_ptr<sdbusplus::async::fdio> fdioInstance;
+};
+
+} // namespace notify_watch
diff --git a/src/cable-monitor/CableConfig.cpp b/src/cable-monitor/CableConfig.cpp
new file mode 100644
index 0000000..2988342
--- /dev/null
+++ b/src/cable-monitor/CableConfig.cpp
@@ -0,0 +1,75 @@
+#include "CableConfig.hpp"
+
+#include <nlohmann/json.hpp>
+#include <phosphor-logging/lg2.hpp>
+#include <sdbusplus/async.hpp>
+
+#include <algorithm>
+#include <exception>
+#include <fstream>
+#include <string>
+
+namespace cable
+{
+
+PHOSPHOR_LOG2_USING;
+
+using json = nlohmann::json;
+
+static auto parseConfigFile(std::string configFile) -> json
+{
+    std::ifstream jsonFile(configFile);
+    if (!jsonFile.is_open())
+    {
+        error("Config file not found: {PATH}", "PATH", configFile);
+        return {};
+    }
+
+    try
+    {
+        return json::parse(jsonFile, nullptr, true);
+    }
+    catch (const json::parse_error& e)
+    {
+        error("Failed to parse config file {PATH}: {ERROR}", "PATH", configFile,
+              "ERROR", e);
+    }
+
+    return {};
+}
+
+auto Config::processConfig(std::string configFile)
+    -> sdbusplus::async::task<Config::Cables>
+{
+    Cables cables{};
+    auto jsonConfig = parseConfigFile(configFile);
+    if (jsonConfig.empty())
+    {
+        co_return cables;
+    }
+
+    static constexpr auto connectedCablesProperty = "ConnectedCables";
+    try
+    {
+        jsonConfig.at(connectedCablesProperty).get_to(cables);
+    }
+    catch (const std::exception& e)
+    {
+        error("Failed to find {PROPERTY} in config, {ERROR}", "PROPERTY",
+              connectedCablesProperty, "ERROR", e);
+        co_return cables;
+    }
+
+    Cables cablesTemp{};
+
+    for (auto cable : cables)
+    {
+        std::replace(cable.begin(), cable.end(), ' ', '_');
+        debug("Config: Cable {NAME}", "NAME", cable);
+        cablesTemp.insert(cable);
+    }
+
+    co_return cablesTemp;
+}
+
+} // namespace cable
diff --git a/src/cable-monitor/CableConfig.hpp b/src/cable-monitor/CableConfig.hpp
new file mode 100644
index 0000000..b8c9fe2
--- /dev/null
+++ b/src/cable-monitor/CableConfig.hpp
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <sdbusplus/async.hpp>
+
+#include <set>
+#include <string>
+
+namespace cable
+{
+
+class Config
+{
+  public:
+    static constexpr auto configFileDir = "/var/lib/cablemonitor";
+    static constexpr auto configFileName = "cable-config.json";
+    explicit Config() = default;
+
+    using Cables = std::set<std::string>;
+
+    /** Process the configuration file */
+    static auto processConfig(std::string configFile)
+        -> sdbusplus::async::task<Cables>;
+};
+
+} // namespace cable
diff --git a/src/cable-monitor/CableEvents.cpp b/src/cable-monitor/CableEvents.cpp
new file mode 100644
index 0000000..a7a132e
--- /dev/null
+++ b/src/cable-monitor/CableEvents.cpp
@@ -0,0 +1,50 @@
+#include "CableEvents.hpp"
+
+#include <phosphor-logging/commit.hpp>
+#include <phosphor-logging/lg2.hpp>
+#include <sdbusplus/async.hpp>
+#include <xyz/openbmc_project/State/Cable/event.hpp>
+
+#include <string>
+
+PHOSPHOR_LOG2_USING;
+
+namespace cable
+{
+
+auto Events::generateCableEvent(Type type, std::string name)
+    -> sdbusplus::async::task<>
+{
+    // Added NO_LINT to bypass clang-tidy warning about STDEXEC_ASSERT as clang
+    // seems to be confused about context being uninitialized.
+    // NOLINTNEXTLINE(clang-analyzer-core.uninitialized.Branch)
+    if (type == Type::connected)
+    {
+        auto pendingEvent = pendingEvents.find(name);
+        if (pendingEvent != pendingEvents.end())
+        {
+            co_await lg2::resolve(ctx, pendingEvent->second);
+
+            using CableConnected = sdbusplus::event::xyz::openbmc_project::
+                state::Cable::CableConnected;
+            co_await lg2::commit(ctx, CableConnected("PORT_ID", name));
+            pendingEvents.erase(pendingEvent);
+        }
+    }
+    else if (type == Type::disconnected)
+    {
+        using CableDisconnected = sdbusplus::error::xyz::openbmc_project::
+            state::Cable::CableDisconnected;
+        auto eventPath =
+            co_await lg2::commit(ctx, CableDisconnected("PORT_ID", name));
+        warning("Generate CableDisconnected for {NAME}", "NAME", name);
+        pendingEvents.emplace(name, eventPath);
+    }
+    else
+    {
+        error("Unknown cable event type");
+    }
+    co_return;
+}
+
+} // namespace cable
diff --git a/src/cable-monitor/CableEvents.hpp b/src/cable-monitor/CableEvents.hpp
new file mode 100644
index 0000000..d3303b2
--- /dev/null
+++ b/src/cable-monitor/CableEvents.hpp
@@ -0,0 +1,39 @@
+#pragma once
+
+#include <sdbusplus/async.hpp>
+#include <sdbusplus/message/native_types.hpp>
+
+#include <string>
+#include <unordered_map>
+
+namespace cable
+{
+
+class Events
+{
+  public:
+    /** @brief Event type */
+    enum class Type
+    {
+        connected,
+        disconnected,
+        unknown
+    };
+
+    Events() = delete;
+    explicit Events(sdbusplus::async::context& ctx) : ctx(ctx) {}
+
+    /** @brief Generate a cable event */
+    auto generateCableEvent(Type type, std::string name)
+        -> sdbusplus::async::task<>;
+
+  private:
+    /** @brief Map type for event name to log event object path */
+    using event_map_t =
+        std::unordered_map<std::string, sdbusplus::message::object_path>;
+
+    sdbusplus::async::context& ctx;
+    event_map_t pendingEvents;
+};
+
+} // namespace cable
diff --git a/src/cable-monitor/CableMonitor.cpp b/src/cable-monitor/CableMonitor.cpp
new file mode 100644
index 0000000..f57a722
--- /dev/null
+++ b/src/cable-monitor/CableMonitor.cpp
@@ -0,0 +1,193 @@
+#include "CableMonitor.hpp"
+
+#include "CableConfig.hpp"
+
+#include <phosphor-logging/lg2.hpp>
+#include <sdbusplus/async.hpp>
+#include <sdbusplus/async/stdexec/__detail/__then.hpp>
+#include <sdbusplus/message/native_types.hpp>
+#include <sdbusplus/server/manager.hpp>
+
+#include <chrono>
+#include <cstring>
+#include <filesystem>
+#include <functional>
+#include <string>
+
+PHOSPHOR_LOG2_USING;
+
+namespace cable
+{
+
+Monitor::Monitor(sdbusplus::async::context& ctx) :
+    ctx(ctx), cableEvents(ctx),
+    entityManager(ctx, {CableInventoryIntf::interface},
+                  std::bind_front(&Monitor::inventoryAddedHandler, this),
+                  std::bind_front(&Monitor::inventoryRemovedHandler, this)),
+    notifyWatch(ctx, Config::configFileDir,
+                std::bind_front(&Monitor::configUpdateHandler, this))
+{
+    ctx.spawn(start());
+}
+
+auto Monitor::inventoryAddedHandler(
+    const sdbusplus::message::object_path& objectPath,
+    const std::string& /*unused*/) -> void
+{
+    debug("Received cable added for {NAME}", "NAME", objectPath);
+    ctx.spawn(processCableAddedAsync(objectPath));
+}
+
+auto Monitor::inventoryRemovedHandler(
+    const sdbusplus::message::object_path& objectPath,
+    const std::string& /*unused*/) -> void
+{
+    debug("Received cable removed for {NAME}", "NAME", objectPath);
+    ctx.spawn(processCableRemovedAsync(objectPath));
+}
+
+auto Monitor::configUpdateHandler(std::string configFileName)
+    -> sdbusplus::async::task<>
+{
+    if (strcmp(Config::configFileName, configFileName.c_str()) != 0)
+    {
+        error("Update config file name {NAME} is not expected", "NAME",
+              configFileName);
+        co_return;
+    }
+    auto configFilePath =
+        std::string(Config::configFileDir) + "/" + configFileName;
+    if (!std::filesystem::exists(configFilePath))
+    {
+        error("Config file {NAME} does not exist", "NAME", configFilePath);
+        co_return;
+    }
+    expectedCables = co_await Config::processConfig(configFilePath);
+    if (expectedCables.empty())
+    {
+        error("No expected cables found in config file {NAME}", "NAME",
+              configFileName);
+        co_return;
+    }
+    co_await entityManager.handleInventoryGet();
+    ctx.spawn(sdbusplus::async::sleep_for(ctx, std::chrono::seconds(5)) |
+              stdexec::then([&]() { reconcileCableData(); }));
+}
+
+auto Monitor::start() -> sdbusplus::async::task<>
+{
+    info("Start cable monitor");
+
+    // Start async handler for cable config update
+    ctx.spawn(notifyWatch.readNotifyAsync());
+
+    // Process the cable config if it already exists
+    auto configFilePath =
+        std::string(Config::configFileDir) + "/" + Config::configFileName;
+    if (std::filesystem::exists(configFilePath))
+    {
+        co_await configUpdateHandler(Config::configFileName);
+    }
+
+    co_return;
+}
+
+auto Monitor::processCableAddedAsync(sdbusplus::message::object_path objectPath)
+    -> sdbusplus::async::task<>
+{
+    auto cableName = objectPath.filename();
+
+    debug("Received cable added for {NAME}", "NAME", cableName);
+
+    if (connectedCables.contains(cableName))
+    {
+        debug("Cable {NAME} is already connected, so skip it", "NAME",
+              cableName);
+        co_return;
+    }
+    else if (expectedCables.empty())
+    {
+        debug("No expected cables yet, so skip cable add for {NAME}", "NAME",
+              cableName);
+        co_return;
+    }
+    else if (!expectedCables.contains(cableName))
+    {
+        debug(
+            "Cable {NAME} is not in expected cables, skip connected event generation",
+            "NAME", cableName);
+        co_return;
+    }
+
+    connectedCables.insert(cableName);
+    co_await cableEvents.generateCableEvent(Events::Type::connected, cableName);
+    debug("New cable {NAME} added", "NAME", cableName);
+
+    co_return;
+}
+
+auto Monitor::processCableRemovedAsync(
+    sdbusplus::message::object_path objectPath) -> sdbusplus::async::task<>
+{
+    auto cableName = objectPath.filename();
+
+    debug("Received cable removed for {NAME}", "NAME", cableName);
+
+    if (expectedCables.empty())
+    {
+        debug("No expected cables yet, so skip cable add for {NAME}", "NAME",
+              cableName);
+        co_return;
+    }
+    else if (!expectedCables.contains(cableName))
+    {
+        debug(
+            "Cable {NAME} is not in expected cables, so skip disconnected event generation",
+            "NAME", cableName);
+        co_return;
+    }
+    else if (!connectedCables.contains(cableName))
+    {
+        debug(
+            "Cable {NAME} is not connected, so skip disconnected event generation",
+            "NAME", cableName);
+        co_return;
+    }
+
+    connectedCables.erase(cableName);
+    co_await cableEvents.generateCableEvent(Events::Type::disconnected,
+                                            cableName);
+    debug("Removed cable {NAME}", "NAME", cableName);
+
+    co_return;
+}
+
+auto Monitor::reconcileCableData() -> void
+{
+    for (const auto& cableName : expectedCables)
+    {
+        if (connectedCables.contains(cableName))
+        {
+            continue;
+        }
+        ctx.spawn(cableEvents.generateCableEvent(Events::Type::disconnected,
+                                                 cableName));
+    }
+}
+
+} // namespace cable
+
+int main()
+{
+    constexpr auto path = "/xyz/openbmc_project/cable_monitor";
+    constexpr auto serviceName = "xyz.openbmc_project.cablemonitor";
+    sdbusplus::async::context ctx;
+    sdbusplus::server::manager_t manager{ctx, path};
+
+    info("Creating cable monitor");
+    cable::Monitor cableMonitor{ctx};
+    ctx.request_name(serviceName);
+
+    ctx.run();
+    return 0;
+}
diff --git a/src/cable-monitor/CableMonitor.hpp b/src/cable-monitor/CableMonitor.hpp
new file mode 100644
index 0000000..3a2d0a0
--- /dev/null
+++ b/src/cable-monitor/CableMonitor.hpp
@@ -0,0 +1,66 @@
+#pragma once
+
+#include "CableConfig.hpp"
+#include "CableEvents.hpp"
+#include "EntityManagerInterface.hpp"
+#include "NotifyWatch.hpp"
+
+#include <sdbusplus/async.hpp>
+#include <sdbusplus/message/native_types.hpp>
+#include <xyz/openbmc_project/Inventory/Item/Cable/client.hpp>
+
+#include <string>
+
+namespace cable
+{
+
+class Monitor;
+
+using CableInventoryIntf =
+    sdbusplus::client::xyz::openbmc_project::inventory::item::Cable<>;
+
+class Monitor
+{
+  public:
+    Monitor() = delete;
+
+    explicit Monitor(sdbusplus::async::context& ctx);
+
+  private:
+    /** @brief  Callback handler for new interfaces added to inventory */
+    auto inventoryAddedHandler(
+        const sdbusplus::message::object_path& objectPath,
+        const std::string& interfaceName) -> void;
+
+    /** @brief Callback handler for interfaces removed from inventory */
+    auto inventoryRemovedHandler(
+        const sdbusplus::message::object_path& objectPath,
+        const std::string& interfaceName) -> void;
+
+    /** @brief Callback handler for async updates to cable JSON configuration */
+    auto configUpdateHandler(std::string configFileName)
+        -> sdbusplus::async::task<>;
+
+    /** @brief Start the Cable Monitor */
+    auto start() -> sdbusplus::async::task<>;
+
+    /** @brief Asynchronously process cable inventory added */
+    auto processCableAddedAsync(sdbusplus::message::object_path objectPath)
+        -> sdbusplus::async::task<>;
+
+    /** @brief Asynchronously process cable inventory removed */
+    auto processCableRemovedAsync(sdbusplus::message::object_path objectPath)
+        -> sdbusplus::async::task<>;
+
+    /** @brief Reconcile connected and expected cable data */
+    auto reconcileCableData() -> void;
+
+    sdbusplus::async::context& ctx;
+    Config::Cables connectedCables;
+    Config::Cables expectedCables;
+    Events cableEvents;
+    entity_manager::EntityManagerInterface entityManager;
+    notify_watch::NotifyWatch notifyWatch;
+};
+
+} // namespace cable
diff --git a/src/cable-monitor/meson.build b/src/cable-monitor/meson.build
new file mode 100644
index 0000000..de8d5db
--- /dev/null
+++ b/src/cable-monitor/meson.build
@@ -0,0 +1,19 @@
+src_inc = include_directories('..')
+phosphor_dbus_interfaces_dep = dependency('phosphor-dbus-interfaces')
+
+executable(
+    'cablemonitor',
+    'CableMonitor.cpp',
+    'CableConfig.cpp',
+    'CableEvents.cpp',
+    dependencies: [
+        default_deps,
+        utils_dep,
+        phosphor_dbus_interfaces_dep,
+        notifywatch_dep,
+        entitymanagerinterface_dep,
+    ],
+    include_directories: src_inc,
+    install: true,
+    install_dir: get_option('libexecdir') / 'dbus-sensors',
+)
diff --git a/src/meson.build b/src/meson.build
index 0929ba7..44c3040 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -87,6 +87,18 @@
     dependencies: default_deps,
 )
 
+notifywatch_a = static_library(
+    'notifywatch_a',
+    'NotifyWatch.cpp',
+    dependencies: default_deps,
+)
+
+notifywatch_dep = declare_dependency(
+    include_directories: ['.'],
+    link_with: [notifywatch_a],
+    dependencies: default_deps,
+)
+
 peci_incdirs = []
 if not meson.get_compiler('cpp').has_header('linux/peci-ioctl.h')
     peci_incdirs = ['../include']
@@ -156,3 +168,7 @@
 if get_option('leakdetector').allowed()
     subdir('leakdetector')
 endif
+
+if get_option('cable-monitor').allowed()
+    subdir('cable-monitor')
+endif