rtu: implement modbus rtu inventory source service

Implement phosphor-modbus-rtu inventory source service based on [1].

[1]: https://gerrit.openbmc.org/c/openbmc/docs/+/77318

Tested: Unit test passes and tested on qemu with simulated modbus
server.
```
root@bmc:~# busctl tree xyz.openbmc_project.ModbusRTU
└─ /xyz
  └─ /xyz/openbmc_project
    └─ /xyz/openbmc_project/inventory_source
      └─ /xyz/openbmc_project/inventory_source/modbus
        ├─ /xyz/openbmc_project/inventory_source/modbus/Heat_Exchanger_12_DevTTYUSB0
        ├─ /xyz/openbmc_project/inventory_source/modbus/Heat_Exchanger_12_DevTTYUSB1
        ├─ /xyz/openbmc_project/inventory_source/modbus/Reservoir_Pumping_Unit_12_DevTTYUSB0
        └─ /xyz/openbmc_project/inventory_source/modbus/Reservoir_Pumping_Unit_12_DevTTYUSB1
root@bmc:~# 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/board
        │ └─ /xyz/openbmc_project/inventory/system/board/Ventura_Modbus
        │   ├─ /xyz/openbmc_project/inventory/system/board/Ventura_Modbus/DevTTYUSB0
        │   ├─ /xyz/openbmc_project/inventory/system/board/Ventura_Modbus/DevTTYUSB1
        │   ├─ /xyz/openbmc_project/inventory/system/board/Ventura_Modbus/Heat_Exchanger
        │   └─ /xyz/openbmc_project/inventory/system/board/Ventura_Modbus/Reservoir_Pumping_Unit
        └─ /xyz/openbmc_project/inventory/system/chassis
          ├─ /xyz/openbmc_project/inventory/system/chassis/Heat_Exchanger_12_DevTTYUSB0
          ├─ /xyz/openbmc_project/inventory/system/chassis/Heat_Exchanger_12_DevTTYUSB1
          ├─ /xyz/openbmc_project/inventory/system/chassis/Reservoir_Pumping_Unit_12_DevTTYUSB0
          └─ /xyz/openbmc_project/inventory/system/chassis/Reservoir_Pumping_Unit_12_DevTTYUSB1

root@bmc:~# busctl introspect xyz.openbmc_project.EntityManager /xyz/openbmc_project/inventory/system/chassis/Heat_Exchanger_12_DevTTYUSB0
NAME                                          TYPE      SIGNATURE RESULT/VALUE                             FLAGS
org.freedesktop.DBus.Introspectable           interface -         -                                        -
.Introspect                                   method    -         s                                        -
org.freedesktop.DBus.Peer                     interface -         -                                        -
.GetMachineId                                 method    -         s                                        -
.Ping                                         method    -         -                                        -
org.freedesktop.DBus.Properties               interface -         -                                        -
.Get                                          method    ss        v                                        -
.GetAll                                       method    s         a{sv}                                    -
.Set                                          method    ssv       -                                        -
.PropertiesChanged                            signal    sa{sv}as  -                                        -
xyz.openbmc_project.AddObject                 interface -         -                                        -
.AddObject                                    method    a{sv}     -                                        -
xyz.openbmc_project.Inventory.Decorator.Asset interface -         -                                        -
.BuildDate                                    property  s         "Unknown"                                emits-change
.Manufacturer                                 property  s         "Unknown"                                emits-change
.Model                                        property  s         "Unknown"                                emits-change
.PartNumber                                   property  s         "Unknown"                                emits-change
.SerialNumber                                 property  s         "Unknown"                                emits-change
.SparePartNumber                              property  s         "ABABABAB"                               emits-change
xyz.openbmc_project.Inventory.Item.Chassis    interface -         -                                        -
.Name                                         property  s         "Heat Exchanger 12 DevTTYUSB0"           emits-change
.Probe                                        property  s         "xyz.openbmc_project.Inventory.Source.M… emits-change
.Type                                         property  s         "Chassis"                                emits-change
root@bmc:~# busctl introspect xyz.openbmc_project.EntityManager /xyz/openbmc_project/inventory/system/chassis/Heat_Exchanger_12_DevTTYUSB1
NAME                                          TYPE      SIGNATURE RESULT/VALUE                             FLAGS
org.freedesktop.DBus.Introspectable           interface -         -                                        -
.Introspect                                   method    -         s                                        -
org.freedesktop.DBus.Peer                     interface -         -                                        -
.GetMachineId                                 method    -         s                                        -
.Ping                                         method    -         -                                        -
org.freedesktop.DBus.Properties               interface -         -                                        -
.Get                                          method    ss        v                                        -
.GetAll                                       method    s         a{sv}                                    -
.Set                                          method    ssv       -                                        -
.PropertiesChanged                            signal    sa{sv}as  -                                        -
xyz.openbmc_project.AddObject                 interface -         -                                        -
.AddObject                                    method    a{sv}     -                                        -
xyz.openbmc_project.Inventory.Decorator.Asset interface -         -                                        -
.BuildDate                                    property  s         "Unknown"                                emits-change
.Manufacturer                                 property  s         "Unknown"                                emits-change
.Model                                        property  s         "Unknown"                                emits-change
.PartNumber                                   property  s         "Unknown"                                emits-change
.SerialNumber                                 property  s         "Unknown"                                emits-change
.SparePartNumber                              property  s         "ABABABAB"                               emits-change
xyz.openbmc_project.Inventory.Item.Chassis    interface -         -                                        -
.Name                                         property  s         "Heat Exchanger 12 DevTTYUSB1"           emits-change
.Probe                                        property  s         "xyz.openbmc_project.Inventory.Source.M… emits-change
.Type                                         property  s         "Chassis"                                emits-change

root@bmc:~# busctl introspect xyz.openbmc_project.EntityManager /xyz/openbmc_project/inventory/system/chassis/Reservoir_Pumping_Unit_12_DevTTYUSB0
NAME                                          TYPE      SIGNATURE RESULT/VALUE                             FLAGS
org.freedesktop.DBus.Introspectable           interface -         -                                        -
.Introspect                                   method    -         s                                        -
org.freedesktop.DBus.Peer                     interface -         -                                        -
.GetMachineId                                 method    -         s                                        -
.Ping                                         method    -         -                                        -
org.freedesktop.DBus.Properties               interface -         -                                        -
.Get                                          method    ss        v                                        -
.GetAll                                       method    s         a{sv}                                    -
.Set                                          method    ssv       -                                        -
.PropertiesChanged                            signal    sa{sv}as  -                                        -
xyz.openbmc_project.AddObject                 interface -         -                                        -
.AddObject                                    method    a{sv}     -                                        -
xyz.openbmc_project.Inventory.Decorator.Asset interface -         -                                        -
.BuildDate                                    property  s         "ABABABAB"                               emits-change
.Manufacturer                                 property  s         "Unknown"                                emits-change
.Model                                        property  s         "ABABABABABABABAB"                       emits-change
.PartNumber                                   property  s         "Unknown"                                emits-change
.SerialNumber                                 property  s         "ABABABABABABABAB"                       emits-change
.SparePartNumber                              property  s         "ABABABAB"                               emits-change
xyz.openbmc_project.Inventory.Item.Chassis    interface -         -                                        -
.Name                                         property  s         "Reservoir Pumping Unit 12 DevTTYUSB0"   emits-change
.Probe                                        property  s         "xyz.openbmc_project.Inventory.Source.M… emits-change
.Type                                         property  s         "Chassis"                                emits-change
root@bmc:~# busctl introspect xyz.openbmc_project.EntityManager /xyz/openbmc_project/inventory/system/chassis/Reservoir_Pumping_Unit_12_DevTTYUSB1
NAME                                          TYPE      SIGNATURE RESULT/VALUE                             FLAGS
org.freedesktop.DBus.Introspectable           interface -         -                                        -
.Introspect                                   method    -         s                                        -
org.freedesktop.DBus.Peer                     interface -         -                                        -
.GetMachineId                                 method    -         s                                        -
.Ping                                         method    -         -                                        -
org.freedesktop.DBus.Properties               interface -         -                                        -
.Get                                          method    ss        v                                        -
.GetAll                                       method    s         a{sv}                                    -
.Set                                          method    ssv       -                                        -
.PropertiesChanged                            signal    sa{sv}as  -                                        -
xyz.openbmc_project.AddObject                 interface -         -                                        -
.AddObject                                    method    a{sv}     -                                        -
xyz.openbmc_project.Inventory.Decorator.Asset interface -         -                                        -
.BuildDate                                    property  s         "ABABABAB"                               emits-change
.Manufacturer                                 property  s         "Unknown"                                emits-change
.Model                                        property  s         "ABABABABABABABAB"                       emits-change
.PartNumber                                   property  s         "Unknown"                                emits-change
.SerialNumber                                 property  s         "ABABABABABABABAB"                       emits-change
.SparePartNumber                              property  s         "ABABABAB"                               emits-change
xyz.openbmc_project.Inventory.Item.Chassis    interface -         -                                        -
.Name                                         property  s         "Reservoir Pumping Unit 12 DevTTYUSB1"   emits-change
.Probe                                        property  s         "xyz.openbmc_project.Inventory.Source.M… emits-change
.Type                                         property  s         "Chassis"                                emits-change
```

Change-Id: Ic0ea739de3833044c95da8164be1e2f3f8e6a063
Signed-off-by: Jagpal Singh Gill <paligill@gmail.com>
diff --git a/rtu/device_manager.cpp b/rtu/device_manager.cpp
new file mode 100644
index 0000000..6d2fbd6
--- /dev/null
+++ b/rtu/device_manager.cpp
@@ -0,0 +1,126 @@
+#include "device_manager.hpp"
+
+#include "port/port_factory.hpp"
+
+#include <phosphor-logging/lg2.hpp>
+#include <sdbusplus/async.hpp>
+#include <sdbusplus/server/manager.hpp>
+#include <xyz/openbmc_project/Configuration/ModbusRTUDetect/client.hpp>
+
+PHOSPHOR_LOG2_USING;
+
+namespace phosphor::modbus::rtu
+{
+
+using ModbusRTUDetectIntf =
+    sdbusplus::client::xyz::openbmc_project::configuration::ModbusRTUDetect<>;
+
+static entity_manager::interface_list_t getInterfaces()
+{
+    entity_manager::interface_list_t interfaces;
+
+    auto portInterfaces = PortIntf::PortFactory::getInterfaces();
+    interfaces.insert(interfaces.end(), portInterfaces.begin(),
+                      portInterfaces.end());
+    interfaces.emplace_back(ModbusRTUDetectIntf::interface);
+
+    return interfaces;
+}
+
+DeviceManager::DeviceManager(sdbusplus::async::context& ctx) :
+    ctx(ctx),
+    entityManager(ctx, getInterfaces(),
+                  std::bind_front(&DeviceManager::processConfigAdded, this),
+                  std::bind_front(&DeviceManager::processConfigRemoved, this))
+{
+    ctx.spawn(entityManager.handleInventoryGet());
+    info("DeviceManager created successfully");
+}
+
+auto DeviceManager::processConfigAdded(
+    const sdbusplus::message::object_path& objectPath,
+    const std::string& interfaceName) -> sdbusplus::async::task<>
+{
+    debug("Config added for {PATH} with {INTF}", "PATH", objectPath, "INTF",
+          interfaceName);
+    if (interfaceName == ModbusRTUDetectIntf::interface && ports.size() == 0)
+    {
+        warning(
+            "Skip processing ModbusRTUDetectIntf::interface as no serial ports detected yet");
+        co_return;
+    }
+
+    auto portInterfaces = PortIntf::PortFactory::getInterfaces();
+    if (std::find(portInterfaces.begin(), portInterfaces.end(),
+                  interfaceName) != portInterfaces.end())
+    {
+        auto config = co_await PortIntf::PortFactory::getConfig(
+            ctx, objectPath, interfaceName);
+        if (!config)
+        {
+            error("Failed to get Port config for {PATH}", "PATH", objectPath);
+            co_return;
+        }
+
+        try
+        {
+            ports[config->name] = PortIntf::PortFactory::create(ctx, *config);
+        }
+        catch (const std::exception& e)
+        {
+            error("Failed to create Port for {PATH} with {ERROR}", "PATH",
+                  objectPath, "ERROR", e);
+            co_return;
+        }
+    }
+    else if (interfaceName == ModbusRTUDetectIntf::interface)
+    {
+        auto res = co_await InventoryIntf::config::getConfig(ctx, objectPath);
+        if (!res)
+        {
+            error("Failed to get Inventory Device config for {PATH}", "PATH",
+                  objectPath);
+            co_return;
+        }
+        auto config = res.value();
+        try
+        {
+            auto inventoryDevice =
+                std::make_unique<InventoryIntf::Device>(ctx, config, ports);
+            ctx.spawn(inventoryDevice->probePorts());
+            inventoryDevices[config.name] = std::move(inventoryDevice);
+        }
+        catch (const std::exception& e)
+        {
+            error("Failed to create Inventory Device for {PATH} with {ERROR}",
+                  "PATH", objectPath, "ERROR", e);
+            co_return;
+        }
+    }
+}
+
+auto DeviceManager::processConfigRemoved(
+    const sdbusplus::message::object_path& /*unused*/,
+    const std::string& /*unused*/) -> sdbusplus::async::task<>
+{
+    // TODO: Implement this
+    co_return;
+}
+
+} // namespace phosphor::modbus::rtu
+
+auto main() -> int
+{
+    constexpr auto path = "/xyz/openbmc_project";
+    constexpr auto serviceName = "xyz.openbmc_project.ModbusRTU";
+    sdbusplus::async::context ctx;
+    sdbusplus::server::manager_t manager{ctx, path};
+
+    info("Creating Modbus device manager at {PATH}", "PATH", path);
+    phosphor::modbus::rtu::DeviceManager deviceManager{ctx};
+
+    ctx.request_name(serviceName);
+
+    ctx.run();
+    return 0;
+}
diff --git a/rtu/device_manager.hpp b/rtu/device_manager.hpp
new file mode 100644
index 0000000..68daa64
--- /dev/null
+++ b/rtu/device_manager.hpp
@@ -0,0 +1,46 @@
+#pragma once
+
+#include "common/entity_manager_interface.hpp"
+#include "inventory/modbus_inventory.hpp"
+#include "port/base_port.hpp"
+
+#include <sdbusplus/async.hpp>
+
+namespace phosphor::modbus::rtu
+{
+
+namespace InventoryIntf = phosphor::modbus::rtu::inventory;
+namespace PortIntf = phosphor::modbus::rtu::port;
+
+class DeviceManager
+{
+  public:
+    DeviceManager() = delete;
+    DeviceManager(const DeviceManager&) = delete;
+    DeviceManager& operator=(const DeviceManager&) = delete;
+    DeviceManager(DeviceManager&&) = delete;
+    DeviceManager& operator=(DeviceManager&&) = delete;
+
+    explicit DeviceManager(sdbusplus::async::context& ctx);
+
+  private:
+    using inventory_device_map_t =
+        std::unordered_map<std::string, std::unique_ptr<InventoryIntf::Device>>;
+    using port_map_t =
+        std::unordered_map<std::string, std::unique_ptr<PortIntf::BasePort>>;
+
+    auto processConfigAdded(const sdbusplus::message::object_path& objectPath,
+                            const std::string& interfaceName)
+        -> sdbusplus::async::task<>;
+
+    auto processConfigRemoved(const sdbusplus::message::object_path& objectPath,
+                              const std::string& interfaceName)
+        -> sdbusplus::async::task<>;
+
+    sdbusplus::async::context& ctx;
+    entity_manager::EntityManagerInterface entityManager;
+    inventory_device_map_t inventoryDevices;
+    port_map_t ports;
+};
+
+} // namespace phosphor::modbus::rtu
diff --git a/rtu/inventory/modbus_inventory.cpp b/rtu/inventory/modbus_inventory.cpp
new file mode 100644
index 0000000..16fa56a
--- /dev/null
+++ b/rtu/inventory/modbus_inventory.cpp
@@ -0,0 +1,435 @@
+#include "modbus_inventory.hpp"
+
+#include "common/entity_manager_interface.hpp"
+
+#include <phosphor-logging/lg2.hpp>
+#include <xyz/openbmc_project/Configuration/ModbusRTUDetect/client.hpp>
+#include <xyz/openbmc_project/Inventory/Item/client.hpp>
+
+#include <flat_map>
+
+namespace phosphor::modbus::rtu::inventory
+{
+PHOSPHOR_LOG2_USING;
+
+namespace config
+{
+
+using BasicVariantType =
+    std::variant<std::vector<std::string>, std::vector<uint8_t>, std::string,
+                 int64_t, uint64_t, double, int32_t, uint32_t, int16_t,
+                 uint16_t, uint8_t, bool>;
+using InventoryBaseConfigMap = std::flat_map<std::string, BasicVariantType>;
+using InventoryData = std::flat_map<std::string, InventoryBaseConfigMap>;
+using ManagedObjectType =
+    std::flat_map<sdbusplus::message::object_path, InventoryData>;
+
+static constexpr std::array<std::pair<std::string_view, Parity>, 3>
+    validParities = {
+        {{"Odd", Parity::odd}, {"Even", Parity::even}, {"None", Parity::none}}};
+
+// TODO: This API will be dropped once EM supports non-indexed interfaces for
+// array objects.
+static auto processModbusAddressInterface(
+    Config& config, const InventoryBaseConfigMap& configMap) -> bool
+{
+    debug("Processing ModbusAddress {NAME}", "NAME", config.name);
+
+    auto rangeStartIter = configMap.find("RangeStart");
+    if (rangeStartIter == configMap.end())
+    {
+        error("Missing RangeStart for {NAME}", "NAME", config.name);
+        return false;
+    }
+    auto rangeStart = std::get<uint64_t>(rangeStartIter->second);
+
+    auto rangeEndIter = configMap.find("RangeEnd");
+    if (rangeEndIter == configMap.end())
+    {
+        error("Missing RangeEnd for {NAME}", "NAME", config.name);
+        return false;
+    }
+    auto rangeEnd = std::get<uint64_t>(rangeEndIter->second);
+
+    auto serialPortIter = configMap.find("SerialPort");
+    if (serialPortIter == configMap.end())
+    {
+        error("Missing SerialPort for {NAME}", "NAME", config.name);
+        return false;
+    }
+    auto serialPort = std::get<std::string>(serialPortIter->second);
+
+    config.addressMap[serialPort].push_back(AddressRange{
+        static_cast<uint8_t>(rangeStart), static_cast<uint8_t>(rangeEnd)});
+
+    debug("ModbusAddress {NAME} {PORT} {START} {END}", "NAME", config.name,
+          "PORT", serialPort, "START", rangeStart, "END", rangeEnd);
+
+    return true;
+}
+
+// TODO: This API will be dropped once EM supports non-indexed interfaces for
+// array objects.
+static auto processModbusRegistersInterface(
+    Config& config, const InventoryBaseConfigMap& configMap) -> bool
+{
+    debug("Processing ModbusRegisters {NAME}", "NAME", config.name);
+    Register registerConfig = {};
+
+    auto nameIter = configMap.find("Name");
+    if (nameIter == configMap.end())
+    {
+        error("Missing Name for {NAME}", "NAME", config.name);
+        return false;
+    }
+    registerConfig.name = std::get<std::string>(nameIter->second);
+
+    auto address = configMap.find("Address");
+    if (address == configMap.end())
+    {
+        error("Missing Address for {NAME}", "NAME", config.name);
+        return false;
+    }
+    registerConfig.offset = std::get<uint64_t>(address->second);
+
+    auto sizeIter = configMap.find("Size");
+    if (sizeIter == configMap.end())
+    {
+        error("Missing Size for {NAME}", "NAME", config.name);
+        return false;
+    }
+    registerConfig.size = std::get<uint64_t>(sizeIter->second);
+
+    config.registers.push_back(registerConfig);
+
+    debug("ModbusRegisters {NAME} {ADDRESS} {SIZE}", "NAME",
+          registerConfig.name, "ADDRESS", registerConfig.offset, "SIZE",
+          registerConfig.size);
+
+    return true;
+}
+
+static auto printConfig(const Config& config) -> void
+{
+    info("Inventory device config: {NAME} {BAUDRATE} {PARITY}", "NAME",
+         config.name, "BAUDRATE", config.baudRate, "PARITY", config.parity);
+
+    for (const auto& [port, addressRanges] : config.addressMap)
+    {
+        for (const auto& addressRange : addressRanges)
+        {
+            info(
+                "Inventory device config: {PORT} {ADDRESS_START} {ADDRESS_END}",
+                "PORT", port, "ADDRESS_START", addressRange.start,
+                "ADDRESS_END", addressRange.end);
+        }
+    }
+
+    for (const auto& registerConfig : config.registers)
+    {
+        info("Inventory device config: {NAME} {ADDRESS} {SIZE}", "NAME",
+             registerConfig.name, "ADDRESS", registerConfig.offset, "SIZE",
+             registerConfig.size);
+    }
+}
+
+auto getConfigSubInterfaces(sdbusplus::async::context& ctx,
+                            sdbusplus::message::object_path objectPath,
+                            Config& config) -> sdbusplus::async::task<bool>
+{
+    constexpr auto modbusAddressInterface =
+        "xyz.openbmc_project.Configuration.ModbusRTUDetect.Address";
+    constexpr auto modbusRegistersInterface =
+        "xyz.openbmc_project.Configuration.ModbusRTUDetect.Registers";
+
+    using InventoryIntf =
+        sdbusplus::client::xyz::openbmc_project::inventory::Item<>;
+
+    constexpr auto entityManager =
+        sdbusplus::async::proxy()
+            .service(entity_manager::EntityManagerInterface::serviceName)
+            .path(InventoryIntf::namespace_path)
+            .interface("org.freedesktop.DBus.ObjectManager");
+
+    for (const auto& [path, deviceConfig] :
+         co_await entityManager.call<ManagedObjectType>(ctx,
+                                                        "GetManagedObjects"))
+    {
+        if (!(path.str).starts_with(objectPath.str))
+        {
+            debug("Skipping device {PATH}", "PATH", path.str);
+            continue;
+        }
+        debug("Processing device {PATH}", "PATH", path.str);
+        for (const auto& [interfaceName, interfaceConfig] : deviceConfig)
+        {
+            if (interfaceName.starts_with(modbusAddressInterface))
+            {
+                if (!processModbusAddressInterface(config, interfaceConfig))
+                {
+                    error("Failed to process {INTERFACE} for {NAME}",
+                          "INTERFACE", modbusAddressInterface, "NAME",
+                          config.name);
+                    co_return false;
+                }
+            }
+            else if (interfaceName.starts_with(modbusRegistersInterface))
+            {
+                if (!processModbusRegistersInterface(config, interfaceConfig))
+                {
+                    error("Failed to process {INTERFACE} for {NAME}",
+                          "INTERFACE", modbusRegistersInterface, "NAME",
+                          config.name);
+                    co_return false;
+                }
+            }
+        }
+    }
+
+    co_return true;
+}
+
+auto getConfig(sdbusplus::async::context& ctx,
+               sdbusplus::message::object_path objectPath)
+    -> sdbusplus::async::task<std::optional<Config>>
+{
+    using ModbusRTUDetectIntf = sdbusplus::client::xyz::openbmc_project::
+        configuration::ModbusRTUDetect<>;
+
+    Config config = {};
+
+    auto properties =
+        co_await ModbusRTUDetectIntf(ctx)
+            .service(entity_manager::EntityManagerInterface::serviceName)
+            .path(objectPath.str)
+            .properties();
+
+    config.name = properties.name;
+    config.baudRate = properties.baud_rate;
+
+    for (const auto& [parityStr, parity] : config::validParities)
+    {
+        if (parityStr == properties.data_parity)
+        {
+            config.parity = parity;
+            break;
+        }
+    }
+    if (config.parity == Parity::unknown)
+    {
+        error("Invalid parity {PARITY} for {NAME}", "PARITY",
+              properties.data_parity, "NAME", properties.name);
+        co_return std::nullopt;
+    }
+
+    if (!co_await getConfigSubInterfaces(ctx, objectPath, config))
+    {
+        co_return std::nullopt;
+    }
+
+    printConfig(config);
+
+    co_return config;
+}
+
+} // namespace config
+
+Device::Device(sdbusplus::async::context& ctx, const config::Config& config,
+               serial_port_map_t& serialPorts) :
+    ctx(ctx), config(config), serialPorts(serialPorts)
+{
+    for (const auto& [serialPort, _] : config.addressMap)
+    {
+        if (serialPorts.find(serialPort) == serialPorts.end())
+        {
+            error("Serial port {PORT} not found for {NAME}", "PORT", serialPort,
+                  "NAME", config.name);
+            continue;
+        }
+    }
+}
+
+auto Device::probePorts() -> sdbusplus::async::task<void>
+{
+    debug("Probing ports for {NAME}", "NAME", config.name);
+    while (!ctx.stop_requested())
+    {
+        for (const auto& [serialPort, _] : config.addressMap)
+        {
+            if (serialPorts.find(serialPort) == serialPorts.end())
+            {
+                continue;
+            }
+            ctx.spawn(probePort(serialPort));
+        }
+        constexpr auto probeInterval = 3;
+        co_await sdbusplus::async::sleep_for(
+            ctx, std::chrono::seconds(probeInterval));
+        debug("Probing ports for {NAME} in {INTERVAL} seconds", "NAME",
+              config.name, "INTERVAL", probeInterval);
+    }
+}
+
+auto Device::probePort(std::string portName) -> sdbusplus::async::task<void>
+{
+    debug("Probing port {PORT}", "PORT", portName);
+
+    auto portConfig = config.addressMap.find(portName);
+    if (portConfig == config.addressMap.end())
+    {
+        error("Serial port {PORT} address map not found for {NAME}", "PORT",
+              portName, "NAME", config.name);
+        co_return;
+    }
+    auto addressRanges = portConfig->second;
+
+    auto port = serialPorts.find(portName);
+    if (port == serialPorts.end())
+    {
+        error("Serial port {PORT} not found for {NAME}", "PORT", portName,
+              "NAME", config.name);
+        co_return;
+    }
+
+    for (const auto& addressRange : addressRanges)
+    {
+        for (auto address = addressRange.start; address <= addressRange.end;
+             address++)
+        {
+            co_await probeDevice(address, portName, *port->second);
+        }
+    }
+}
+
+auto Device::probeDevice(uint8_t address, const std::string& portName,
+                         SerialPortIntf& port) -> sdbusplus::async::task<void>
+{
+    debug("Probing device at {ADDRESS} on port {PORT}", "ADDRESS", address,
+          "PORT", portName);
+
+    if (config.registers.size() == 0)
+    {
+        error("No registers configured for {NAME}", "NAME", config.name);
+        co_return;
+    }
+    auto probeRegister = config.registers[0].offset;
+    auto registers = std::vector<uint16_t>(config.registers[0].size);
+
+    auto sourceId = std::to_string(address) + "_" + portName;
+
+    auto ret = co_await port.readHoldingRegisters(
+        address, probeRegister, config.baudRate, config.parity, registers);
+    if (ret)
+    {
+        if (inventorySources.find(sourceId) == inventorySources.end())
+        {
+            debug("Device found at {ADDRESS}", "ADDRESS", address);
+            co_await addInventorySource(address, portName, port);
+        }
+        else
+        {
+            debug("Device already exists at {ADDRESS}", "ADDRESS", address);
+        }
+    }
+    else
+    {
+        if (inventorySources.find(sourceId) != inventorySources.end())
+        {
+            warning(
+                "Device removed at {ADDRESS} due to probe failure for {PROBE_REGISTER}",
+                "ADDRESS", address, "PROBE_REGISTER", probeRegister);
+            inventorySources[sourceId]->emit_removed();
+            inventorySources.erase(sourceId);
+        }
+    }
+}
+
+static auto fillInventorySourceProperties(
+    InventorySourceIntf::properties_t& properties, const std::string& regName,
+    std::string& strValue) -> void
+{
+    constexpr auto partNumber = "PartNumber";
+    constexpr auto sparePartNumber = "SparePartNumber";
+    constexpr auto serialNumber = "SerialNumber";
+    constexpr auto buildDate = "BuildDate";
+    constexpr auto model = "Model";
+    constexpr auto manufacturer = "Manufacturer";
+
+    if (regName == partNumber)
+    {
+        properties.part_number = strValue;
+    }
+    else if (regName == sparePartNumber)
+    {
+        properties.spare_part_number = strValue;
+    }
+    else if (regName == serialNumber)
+    {
+        properties.serial_number = strValue;
+    }
+    else if (regName == buildDate)
+    {
+        properties.build_date = strValue;
+    }
+    else if (regName == model)
+    {
+        properties.model = strValue;
+    }
+    else if (regName == manufacturer)
+    {
+        properties.manufacturer = strValue;
+    }
+}
+
+auto Device::addInventorySource(uint8_t address, const std::string& portName,
+                                SerialPortIntf& port)
+    -> sdbusplus::async::task<void>
+{
+    InventorySourceIntf::properties_t properties;
+
+    for (const auto& reg : config.registers)
+    {
+        auto registers = std::vector<uint16_t>(reg.size);
+        auto ret = co_await port.readHoldingRegisters(
+            address, reg.offset, config.baudRate, config.parity, registers);
+        if (!ret)
+        {
+            error(
+                "Failed to read holding registers {NAME} for {DEVICE_ADDRESS}",
+                "NAME", reg.name, "DEVICE_ADDRESS", address);
+            continue;
+        }
+
+        std::string strValue = "";
+
+        // Reswap bytes in each register for string conversion
+        for (const auto& value : registers)
+        {
+            strValue += static_cast<char>((value >> 8) & 0xFF);
+            strValue += static_cast<char>(value & 0xFF);
+        }
+
+        fillInventorySourceProperties(properties, reg.name, strValue);
+    }
+
+    auto pathSuffix =
+        config.name + " " + std::to_string(address) + " " + portName;
+
+    properties.name = pathSuffix;
+    properties.address = address;
+    properties.link_tty = portName;
+
+    std::replace(pathSuffix.begin(), pathSuffix.end(), ' ', '_');
+
+    auto objectPath =
+        std::string(InventorySourceIntf::namespace_path) + "/" + pathSuffix;
+    auto sourceId = std::to_string(address) + "_" + portName;
+
+    inventorySources[sourceId] = std::make_unique<InventorySourceIntf>(
+        ctx, objectPath.c_str(), properties);
+    inventorySources[sourceId]->emit_added();
+
+    info("Added InventorySource at {PATH}", "PATH", objectPath);
+}
+
+} // namespace phosphor::modbus::rtu::inventory
diff --git a/rtu/inventory/modbus_inventory.hpp b/rtu/inventory/modbus_inventory.hpp
new file mode 100644
index 0000000..ed909c7
--- /dev/null
+++ b/rtu/inventory/modbus_inventory.hpp
@@ -0,0 +1,92 @@
+#pragma once
+
+#include "modbus/modbus.hpp"
+#include "port/base_port.hpp"
+
+#include <sdbusplus/async.hpp>
+#include <xyz/openbmc_project/Inventory/Source/Modbus/FRU/aserver.hpp>
+
+#include <cstdint>
+#include <map>
+#include <string>
+#include <tuple>
+#include <vector>
+
+namespace phosphor::modbus::rtu::inventory
+{
+
+class Device;
+
+namespace ModbusIntf = phosphor::modbus::rtu;
+using SerialPortIntf = phosphor::modbus::rtu::port::BasePort;
+using InventorySourceIntf =
+    sdbusplus::aserver::xyz::openbmc_project::inventory::source::modbus::FRU<
+        Device>;
+
+namespace config
+{
+
+struct Register
+{
+    std::string name = "unknown";
+    uint16_t offset = 0;
+    uint8_t size = 0;
+};
+
+struct AddressRange
+{
+    uint8_t start;
+    uint8_t end;
+};
+
+struct Config
+{
+    using address_range_arr_t = std::vector<AddressRange>;
+    using port_address_map_t =
+        std::map<std::string,
+                 address_range_arr_t>; // <port name, device address range list>
+
+    std::string name = "unknown";
+    port_address_map_t addressMap = {};
+    std::vector<Register> registers = {};
+    ModbusIntf::Parity parity = ModbusIntf::Parity::unknown;
+    uint32_t baudRate = 0;
+};
+
+auto getConfig(sdbusplus::async::context& ctx,
+               sdbusplus::message::object_path objectPath)
+    -> sdbusplus::async::task<std::optional<Config>>;
+
+} // namespace config
+
+class Device
+{
+  public:
+    Device() = delete;
+    using serial_port_map_t =
+        std::unordered_map<std::string, std::unique_ptr<SerialPortIntf>>;
+
+    explicit Device(sdbusplus::async::context& ctx,
+                    const config::Config& config,
+                    serial_port_map_t& serialPorts);
+
+    auto probePorts() -> sdbusplus::async::task<void>;
+
+    auto probePort(std::string portName) -> sdbusplus::async::task<void>;
+
+    auto probeDevice(uint8_t address, const std::string& portName,
+                     SerialPortIntf& port) -> sdbusplus::async::task<void>;
+
+    auto addInventorySource(uint8_t address, const std::string& portName,
+                            SerialPortIntf& port)
+        -> sdbusplus::async::task<void>;
+
+  private:
+    sdbusplus::async::context& ctx;
+    const config::Config config;
+    serial_port_map_t& serialPorts;
+    std::map<std::string, std::unique_ptr<InventorySourceIntf>>
+        inventorySources;
+};
+
+} // namespace phosphor::modbus::rtu::inventory
diff --git a/rtu/meson.build b/rtu/meson.build
index 2c385b2..fa1bd9f 100644
--- a/rtu/meson.build
+++ b/rtu/meson.build
@@ -22,3 +22,25 @@
     link_with: [modbus_rtu_lib, modbus_rtu_port_lib],
     dependencies: [default_deps],
 )
+
+inventory_src = files('inventory/modbus_inventory.cpp')
+
+executable(
+    'phosphor-modbus-rtu',
+    ['device_manager.cpp', inventory_src],
+    include_directories: ['.', common_include],
+    dependencies: [default_deps],
+    link_with: [modbus_common_lib, modbus_rtu_lib, modbus_rtu_port_lib],
+    install: true,
+    install_dir: get_option('libexecdir') / 'phosphor-modbus',
+)
+
+systemd_system_unit_dir = dependency('systemd').get_variable(
+    'systemdsystemunitdir',
+    pkgconfig_define: ['prefix', get_option('prefix')],
+)
+
+install_data(
+    'xyz.openbmc_project.ModbusRTU.service',
+    install_dir: systemd_system_unit_dir,
+)
diff --git a/rtu/xyz.openbmc_project.ModbusRTU.service b/rtu/xyz.openbmc_project.ModbusRTU.service
new file mode 100644
index 0000000..564fc94
--- /dev/null
+++ b/rtu/xyz.openbmc_project.ModbusRTU.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=Modbus RTU Service
+Requires=xyz.openbmc_project.EntityManager.service
+After=xyz.openbmc_project.EntityManager.service
+
+[Service]
+Type=dbus
+BusName=xyz.openbmc_project.ModbusRTU
+Restart=always
+RestartSec=5
+ExecStart=/usr/libexec/phosphor-modbus/phosphor-modbus-rtu
+
+[Install]
+WantedBy=multi-user.target
diff --git a/tests/meson.build b/tests/meson.build
index 2be37a5..bcc4fad 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -50,3 +50,15 @@
         include_directories: ['.', common_include],
     ),
 )
+
+test(
+    'test_inventory',
+    executable(
+        'test_inventory',
+        'test_inventory.cpp',
+        'modbus_server_tester.cpp',
+        inventory_src,
+        dependencies: [gtest_dep, gmock_dep, default_deps, modbus_rtu_dep],
+        include_directories: ['.', common_include],
+    ),
+)
diff --git a/tests/modbus_server_tester.cpp b/tests/modbus_server_tester.cpp
index a08e51f..fe1b825 100644
--- a/tests/modbus_server_tester.cpp
+++ b/tests/modbus_server_tester.cpp
@@ -21,11 +21,15 @@
 constexpr uint8_t readHoldingRegistersErrorFunctionCode = 0x83;
 
 ServerTester::ServerTester(sdbusplus::async::context& ctx, int fd) :
-    fd(fd), fdioInstance(ctx, fd)
+    fd(fd), fdioInstance(ctx, fd), mutex("TestMutex")
 {}
 
 auto ServerTester::processRequests() -> sdbusplus::async::task<void>
 {
+    // Acquire lock to guard against concurrent access to fdioInstance
+    sdbusplus::async::lock_guard lg{mutex};
+    co_await lg.lock();
+
     MessageIntf request;
     co_await fdioInstance.next();
     auto ret = read(fd, request.raw.data(), request.raw.size());
@@ -94,6 +98,11 @@
     }
 }
 
+static inline void checkRequestSize(size_t requestSize, size_t expectedSize)
+{
+    EXPECT_EQ(requestSize, expectedSize) << "Invalid request size";
+}
+
 void ServerTester::processReadHoldingRegisters(
     MessageIntf& request, size_t requestSize, MessageIntf& response,
     bool& segmentedResponse)
@@ -113,11 +122,11 @@
     uint16_t registerOffset = request.raw[2] << 8 | request.raw[3];
     uint16_t registerCount = request.raw[4] << 8 | request.raw[5];
 
-    EXPECT_EQ(registerCount, testSuccessReadHoldingRegisterCount);
-
     if (registerOffset == testSuccessReadHoldingRegisterOffset ||
         registerOffset == testSuccessReadHoldingRegisterSegmentedOffset)
     {
+        checkRequestSize(registerCount, testSuccessReadHoldingRegisterCount);
+
         response << request.raw[0] << request.raw[1]
                  << uint8_t(2 * registerCount)
                  << uint16_t(testSuccessReadHoldingRegisterResponse[0])
@@ -126,8 +135,22 @@
         segmentedResponse =
             (registerOffset == testSuccessReadHoldingRegisterSegmentedOffset);
     }
+    else if (registerOffset == testReadHoldingRegisterModelOffset)
+    {
+        checkRequestSize(registerCount, testReadHoldingRegisterModelCount);
+
+        response << request.raw[0] << request.raw[1]
+                 << uint8_t(2 * testReadHoldingRegisterModelCount);
+        for (size_t i = 0; i < testReadHoldingRegisterModelCount; i++)
+        {
+            response << uint16_t(testReadHoldingRegisterModel[i]);
+        }
+        response.appendCRC();
+    }
     else if (registerOffset == testFailureReadHoldingRegister)
     {
+        checkRequestSize(registerCount, testSuccessReadHoldingRegisterCount);
+
         response << request.raw[0]
                  << (uint8_t)readHoldingRegistersErrorFunctionCode
                  << uint8_t(RTUIntf::ModbusExceptionCode::illegalFunctionCode);
diff --git a/tests/modbus_server_tester.hpp b/tests/modbus_server_tester.hpp
index 79955a0..c9b4416 100644
--- a/tests/modbus_server_tester.hpp
+++ b/tests/modbus_server_tester.hpp
@@ -14,6 +14,7 @@
     friend class ServerTester;
 };
 
+// Read Holding Registers Testing Constants
 static constexpr uint8_t testDeviceAddress = 0xa;
 constexpr uint16_t testSuccessReadHoldingRegisterOffset = 0x0102;
 constexpr uint16_t testSuccessReadHoldingRegisterCount = 0x2;
@@ -22,6 +23,14 @@
     testSuccessReadHoldingRegisterResponse = {0x1234, 0x5678};
 constexpr uint16_t testFailureReadHoldingRegister = 0x0105;
 
+// Device Inventory Testing Constants
+constexpr uint16_t testReadHoldingRegisterModelOffset = 0x0112;
+constexpr uint16_t testReadHoldingRegisterModelCount = 0x8;
+constexpr std::array<uint16_t, testReadHoldingRegisterModelCount>
+    testReadHoldingRegisterModel = {0x5244, 0x4630, 0x3430, 0x4453,
+                                    0x5335, 0x3139, 0x0000, 0x3000};
+constexpr std::string testReadHoldingRegisterModelStr = "RDF040DSS519";
+
 class ServerTester
 {
   public:
@@ -39,5 +48,6 @@
 
     int fd;
     sdbusplus::async::fdio fdioInstance;
+    sdbusplus::async::mutex mutex;
 };
 } // namespace phosphor::modbus::test
diff --git a/tests/test_inventory.cpp b/tests/test_inventory.cpp
new file mode 100644
index 0000000..b54b3a7
--- /dev/null
+++ b/tests/test_inventory.cpp
@@ -0,0 +1,171 @@
+#include "inventory/modbus_inventory.hpp"
+#include "modbus_server_tester.hpp"
+#include "port/base_port.hpp"
+
+#include <fcntl.h>
+
+#include <xyz/openbmc_project/Inventory/Source/Modbus/FRU/client.hpp>
+
+#include <gtest/gtest.h>
+
+using namespace std::literals;
+using namespace testing;
+using InventorySourceIntf =
+    sdbusplus::client::xyz::openbmc_project::inventory::source::modbus::FRU<>;
+
+namespace TestIntf = phosphor::modbus::test;
+namespace ModbusIntf = phosphor::modbus::rtu;
+namespace PortIntf = phosphor::modbus::rtu::port;
+namespace PortConfigIntf = PortIntf::config;
+namespace InventoryIntf = phosphor::modbus::rtu::inventory;
+namespace InventoryConfigIntf = InventoryIntf::config;
+
+class MockPort : public PortIntf::BasePort
+{
+  public:
+    MockPort(sdbusplus::async::context& ctx,
+             const PortConfigIntf::Config& config,
+             const std::string& devicePath) : BasePort(ctx, config, devicePath)
+    {}
+};
+
+class InventoryTest : public ::testing::Test
+{
+  public:
+    PortConfigIntf::Config portConfig;
+    static constexpr const char* clientDevicePath =
+        "/tmp/ttyInventoryTestPort0";
+    static constexpr const char* serverDevicePath =
+        "/tmp/ttyInventoryTestPort1";
+    static constexpr const auto defaultBaudeRate = "b115200";
+    static constexpr const auto deviceName = "Test1";
+    static constexpr auto serviceName = "xyz.openbmc_project.TestModbusRTU";
+    int socat_pid = -1;
+    sdbusplus::async::context ctx;
+    int fdClient = -1;
+    std::unique_ptr<TestIntf::ServerTester> serverTester;
+    int fdServer = -1;
+
+    InventoryTest()
+    {
+        portConfig.name = "TestPort1";
+        portConfig.portMode = PortConfigIntf::PortMode::rs485;
+        portConfig.baudRate = 115200;
+        portConfig.rtsDelay = 1;
+
+        std::string socatCmd = std::format(
+            "socat -x -v -d -d pty,link={},rawer,echo=0,parenb,{} pty,link={},rawer,echo=0,parenb,{} & echo $!",
+            serverDevicePath, defaultBaudeRate, clientDevicePath,
+            defaultBaudeRate);
+
+        // Start socat in the background and capture its PID
+        FILE* fp = popen(socatCmd.c_str(), "r");
+        EXPECT_NE(fp, nullptr) << "Failed to start socat: " << strerror(errno);
+        EXPECT_GT(fscanf(fp, "%d", &socat_pid), 0);
+        pclose(fp);
+
+        // Wait for socat to start up
+        sleep(1);
+
+        fdClient = open(clientDevicePath, O_RDWR | O_NOCTTY | O_NONBLOCK);
+        EXPECT_NE(fdClient, -1)
+            << "Failed to open serial port " << clientDevicePath
+            << " with error: " << strerror(errno);
+
+        fdServer = open(serverDevicePath, O_RDWR | O_NOCTTY | O_NONBLOCK);
+        EXPECT_NE(fdServer, -1)
+            << "Failed to open serial port " << serverDevicePath
+            << " with error: " << strerror(errno);
+
+        ctx.request_name(serviceName);
+
+        serverTester = std::make_unique<TestIntf::ServerTester>(ctx, fdServer);
+    }
+
+    ~InventoryTest() noexcept override
+    {
+        if (fdClient != -1)
+        {
+            close(fdClient);
+            fdClient = -1;
+        }
+        if (fdServer != -1)
+        {
+            close(fdServer);
+            fdServer = -1;
+        }
+        kill(socat_pid, SIGTERM);
+    }
+
+    auto testInventorySourceCreation(std::string objPath)
+        -> sdbusplus::async::task<void>
+    {
+        InventoryConfigIntf::Config::port_address_map_t addressMap;
+        addressMap[portConfig.name] = {{.start = TestIntf::testDeviceAddress,
+                                        .end = TestIntf::testDeviceAddress}};
+        InventoryConfigIntf::Config deviceConfig = {
+            .name = deviceName,
+            .addressMap = addressMap,
+            .registers = {{"Model",
+                           TestIntf::testReadHoldingRegisterModelOffset,
+                           TestIntf::testReadHoldingRegisterModelCount}},
+            .parity = ModbusIntf::Parity::none,
+            .baudRate = 115200};
+        InventoryIntf::Device::serial_port_map_t ports;
+        ports[portConfig.name] =
+            std::make_unique<MockPort>(ctx, portConfig, clientDevicePath);
+
+        auto inventoryDevice =
+            std::make_unique<InventoryIntf::Device>(ctx, deviceConfig, ports);
+
+        co_await inventoryDevice->probePorts();
+
+        // Create InventorySource client interface to read back D-Bus properties
+        auto properties = co_await InventorySourceIntf(ctx)
+                              .service(serviceName)
+                              .path(objPath)
+                              .properties();
+
+        constexpr auto defaultInventoryValue = "Unknown";
+
+        EXPECT_EQ(properties.name,
+                  std::format("{} {} {}", deviceName,
+                              TestIntf::testDeviceAddress, portConfig.name))
+            << "Name mismatch";
+        EXPECT_EQ(properties.address, TestIntf::testDeviceAddress)
+            << "Address mismatch";
+        EXPECT_EQ(properties.link_tty, portConfig.name) << "Link TTY mismatch";
+        EXPECT_EQ(properties.model, TestIntf::testReadHoldingRegisterModelStr)
+            << "Model mismatch";
+        EXPECT_EQ(properties.serial_number, defaultInventoryValue)
+            << "Part Number mismatch";
+
+        co_return;
+    }
+
+    void SetUp() override
+    {
+        // Process request for probe device call
+        ctx.spawn(serverTester->processRequests());
+
+        // Process request to read `Model` holding register call
+        ctx.spawn(sdbusplus::async::sleep_for(ctx, 1s) |
+                  sdbusplus::async::execution::then([&]() {
+                      ctx.spawn(serverTester->processRequests());
+                  }));
+    }
+};
+
+TEST_F(InventoryTest, TestAddInventorySource)
+{
+    auto objPath =
+        std::format("{}/{}_{}_{}", InventorySourceIntf::namespace_path,
+                    deviceName, TestIntf::testDeviceAddress, portConfig.name);
+
+    ctx.spawn(testInventorySourceCreation(objPath));
+
+    ctx.spawn(sdbusplus::async::sleep_for(ctx, 1s) |
+              sdbusplus::async::execution::then([&]() { ctx.request_stop(); }));
+
+    ctx.run();
+}