modbus_rtu_lib: implement library APIs

Implement modbus-rtu library APIs which will be used by
phosphor-modbus-rtu service based on [1].

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

Tested:
Added a Mock Modbus RTU server using socat which intercepts and replies
to modbus messages for testing.
```
> meson test -C builddir
ninja: Entering directory `/host/repos/Modbus/phosphor-modbus/builddir'
ninja: no work to do.
1/2 test_modbus_commands        OK              0.01s
2/2 test_modbus                 OK              6.02s

Ok:                2
Fail:              0
```

Change-Id: I66cdc8fd930dd6f7ad6888116d1419ad8f8b8ed8
Signed-off-by: Jagpal Singh Gill <paligill@gmail.com>
diff --git a/tests/Readme.md b/tests/Readme.md
new file mode 100644
index 0000000..c8c265b
--- /dev/null
+++ b/tests/Readme.md
@@ -0,0 +1,23 @@
+# Testing
+
+## Mocked Modbus Testing
+
+The `socat` command is utilized to create pseudo-terminals (PTYs), enabling the
+setup of virtual serial ports for communication between a Modbus client and
+server. In this testing framework, the Modbus client (implemented as a test
+client using gtest) sends Modbus commands to a test server, which processes
+these requests and returns responses. The test environment setup is responsible
+for configuring the pseudo-terminals and launching the test server instance. The
+server logic resides in `modbus_server_tester.cpp::ServerTester`, with further
+details provided in the following sections.
+
+### ServerTester
+
+`ServerTester` acts as a mock Modbus server, intercepting Modbus messages and
+generating appropriate responses. The replies are determined by the mocked
+Modbus addresses, allowing targeted code paths to be exercised on the client
+side. `ServerTester` is capable of handling both single and segmented response
+scenarios, and also provides error responses to facilitate negative testing. The
+test server currently handles the following Modbus command:
+
+- ReadHoldingRegisters
diff --git a/tests/meson.build b/tests/meson.build
index a02a690..e6f5afc 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -28,3 +28,14 @@
         include_directories: ['.'],
     ),
 )
+
+test(
+    'test_modbus',
+    executable(
+        'test_modbus',
+        'test_modbus.cpp',
+        'modbus_server_tester.cpp',
+        dependencies: [gtest_dep, gmock_dep, default_deps, modbus_rtu_dep],
+        include_directories: ['.'],
+    ),
+)
diff --git a/tests/modbus_server_tester.cpp b/tests/modbus_server_tester.cpp
new file mode 100644
index 0000000..a08e51f
--- /dev/null
+++ b/tests/modbus_server_tester.cpp
@@ -0,0 +1,142 @@
+#include "modbus_server_tester.hpp"
+
+#include "modbus/modbus.hpp"
+#include "modbus/modbus_commands.hpp"
+#include "modbus/modbus_exception.hpp"
+
+#include <phosphor-logging/lg2.hpp>
+#include <sdbusplus/async.hpp>
+
+#include <gtest/gtest.h>
+
+namespace phosphor::modbus::test
+{
+
+PHOSPHOR_LOG2_USING;
+
+namespace RTUIntf = phosphor::modbus::rtu;
+using namespace std::literals;
+
+constexpr uint8_t readHoldingRegistersFunctionCode = 0x3;
+constexpr uint8_t readHoldingRegistersErrorFunctionCode = 0x83;
+
+ServerTester::ServerTester(sdbusplus::async::context& ctx, int fd) :
+    fd(fd), fdioInstance(ctx, fd)
+{}
+
+auto ServerTester::processRequests() -> sdbusplus::async::task<void>
+{
+    MessageIntf request;
+    co_await fdioInstance.next();
+    auto ret = read(fd, request.raw.data(), request.raw.size());
+    // Request message need to be at least 4 bytes long - address(1),
+    // function code(1), ..., CRC(2)
+    if (ret < 4)
+    {
+        error("Invalid Server message size {SIZE}, drop it", "SIZE", ret);
+        co_return;
+    }
+
+    MessageIntf response;
+    bool segmentedResponse = false;
+    processMessage(request, ret, response, segmentedResponse);
+
+    if (!segmentedResponse)
+    {
+        ret = write(fd, response.raw.data(), response.len);
+        if (ret < 0)
+        {
+            error("Failed to send response {ERROR}", "ERROR", strerror(errno));
+        }
+        co_return;
+    }
+
+    // Segmented response
+    ret = write(fd, response.raw.data(), response.len - 2);
+    if (ret < 0)
+    {
+        error("Failed to send 1st segment response {ERROR}", "ERROR",
+              strerror(errno));
+        co_return;
+    }
+
+    debug("First segment sent successfully");
+
+    ret = write(fd, response.raw.data() + response.len - 2, 2);
+    if (ret < 0)
+    {
+        error("Failed to send 2nd segment response {ERROR}", "ERROR",
+              strerror(errno));
+        co_return;
+    }
+
+    debug("Second segment sent successfully");
+
+    co_return;
+}
+
+void ServerTester::processMessage(MessageIntf& request, size_t requestSize,
+                                  MessageIntf& response,
+                                  bool& segmentedResponse)
+{
+    EXPECT_EQ(request.address, testDeviceAddress) << "Invalid device address";
+
+    switch (request.functionCode)
+    {
+        case readHoldingRegistersFunctionCode:
+            processReadHoldingRegisters(request, requestSize, response,
+                                        segmentedResponse);
+            break;
+        default:
+            FAIL() << "Server received unknown request function code "
+                   << request.functionCode;
+            break;
+    }
+}
+
+void ServerTester::processReadHoldingRegisters(
+    MessageIntf& request, size_t requestSize, MessageIntf& response,
+    bool& segmentedResponse)
+{
+    constexpr size_t expectedRequestSize = 8;
+
+    if (requestSize != expectedRequestSize)
+    {
+        FAIL() << "Invalid readHoldingRegisters request size:" << requestSize
+               << ", drop it";
+        return;
+    }
+
+    // NOTE: This code deliberately avoids using any packing helpers from
+    // message.hpp. This ensures that message APIs are tested as intended on the
+    // client side.
+    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)
+    {
+        response << request.raw[0] << request.raw[1]
+                 << uint8_t(2 * registerCount)
+                 << uint16_t(testSuccessReadHoldingRegisterResponse[0])
+                 << uint16_t(testSuccessReadHoldingRegisterResponse[1]);
+        response.appendCRC();
+        segmentedResponse =
+            (registerOffset == testSuccessReadHoldingRegisterSegmentedOffset);
+    }
+    else if (registerOffset == testFailureReadHoldingRegister)
+    {
+        response << request.raw[0]
+                 << (uint8_t)readHoldingRegistersErrorFunctionCode
+                 << uint8_t(RTUIntf::ModbusExceptionCode::illegalFunctionCode);
+        response.appendCRC();
+    }
+    else
+    {
+        FAIL() << "Invalid register offset:" << registerOffset;
+    }
+}
+
+} // namespace phosphor::modbus::test
diff --git a/tests/modbus_server_tester.hpp b/tests/modbus_server_tester.hpp
new file mode 100644
index 0000000..79955a0
--- /dev/null
+++ b/tests/modbus_server_tester.hpp
@@ -0,0 +1,43 @@
+#pragma once
+
+#include "modbus/modbus_message.hpp"
+
+#include <sdbusplus/async.hpp>
+
+using MessageBase = phosphor::modbus::rtu::Message;
+
+namespace phosphor::modbus::test
+{
+
+class MessageIntf : public MessageBase
+{
+    friend class ServerTester;
+};
+
+static constexpr uint8_t testDeviceAddress = 0xa;
+constexpr uint16_t testSuccessReadHoldingRegisterOffset = 0x0102;
+constexpr uint16_t testSuccessReadHoldingRegisterCount = 0x2;
+constexpr uint16_t testSuccessReadHoldingRegisterSegmentedOffset = 0x0103;
+constexpr std::array<uint16_t, testSuccessReadHoldingRegisterCount>
+    testSuccessReadHoldingRegisterResponse = {0x1234, 0x5678};
+constexpr uint16_t testFailureReadHoldingRegister = 0x0105;
+
+class ServerTester
+{
+  public:
+    explicit ServerTester(sdbusplus::async::context& ctx, int fd);
+
+    auto processRequests() -> sdbusplus::async::task<void>;
+
+  private:
+    void processMessage(MessageIntf& request, size_t requestSize,
+                        MessageIntf& response, bool& segmentedResponse);
+
+    void processReadHoldingRegisters(MessageIntf& request, size_t requestSize,
+                                     MessageIntf& response,
+                                     bool& segmentedResponse);
+
+    int fd;
+    sdbusplus::async::fdio fdioInstance;
+};
+} // namespace phosphor::modbus::test
diff --git a/tests/test_modbus.cpp b/tests/test_modbus.cpp
new file mode 100644
index 0000000..c272f96
--- /dev/null
+++ b/tests/test_modbus.cpp
@@ -0,0 +1,138 @@
+#include "modbus/modbus.hpp"
+#include "modbus_server_tester.hpp"
+
+#include <fcntl.h>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+using namespace std::literals;
+
+namespace RTUIntf = phosphor::modbus::rtu;
+using ModbusIntf = RTUIntf::Modbus;
+namespace TestIntf = phosphor::modbus::test;
+
+class ModbusTest : public ::testing::Test
+{
+  public:
+    static constexpr const char* clientDevicePath = "/tmp/ttyV0";
+    static constexpr const char* serverDevicePath = "/tmp/ttyV1";
+    static constexpr const auto defaultBaudeRate = "b115200";
+    int socat_pid = -1;
+    sdbusplus::async::context ctx;
+    std::unique_ptr<ModbusIntf> modbus;
+    int fdClient = -1;
+    std::unique_ptr<TestIntf::ServerTester> serverTester;
+    int fdServer = -1;
+
+    ModbusTest()
+    {
+        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);
+
+        modbus = std::make_unique<ModbusIntf>(ctx, fdClient, 115200, 0);
+
+        fdServer = open(serverDevicePath, O_RDWR | O_NOCTTY | O_NONBLOCK);
+        EXPECT_NE(fdServer, -1)
+            << "Failed to open serial port " << serverDevicePath
+            << " with error: " << strerror(errno);
+
+        serverTester = std::make_unique<TestIntf::ServerTester>(ctx, fdServer);
+    }
+
+    ~ModbusTest() noexcept override
+    {
+        if (fdClient != -1)
+        {
+            close(fdClient);
+            fdClient = -1;
+        }
+        if (fdServer != -1)
+        {
+            close(fdServer);
+            fdServer = -1;
+        }
+        kill(socat_pid, SIGTERM);
+    }
+
+    void SetUp() override
+    {
+        ctx.spawn(serverTester->processRequests());
+    }
+
+    auto TestHoldingRegisters(uint16_t registerOffset, bool res)
+        -> sdbusplus::async::task<void>
+    {
+        std::cout << "TestHoldingRegisters() start" << std::endl;
+
+        std::vector<uint16_t> registers(
+            TestIntf::testSuccessReadHoldingRegisterCount);
+
+        auto ret = co_await modbus->readHoldingRegisters(
+            TestIntf::testDeviceAddress, registerOffset, registers);
+
+        EXPECT_EQ(ret, res) << "Failed to read holding registers";
+
+        if (!res)
+        {
+            co_return;
+        }
+
+        for (auto i = 0; i < TestIntf::testSuccessReadHoldingRegisterCount; i++)
+        {
+            EXPECT_EQ(registers[i],
+                      TestIntf::testSuccessReadHoldingRegisterResponse[i]);
+        }
+
+        co_return;
+    }
+};
+
+TEST_F(ModbusTest, TestReadHoldingRegisterSuccess)
+{
+    ctx.spawn(TestHoldingRegisters(
+        TestIntf::testSuccessReadHoldingRegisterOffset, true));
+
+    ctx.spawn(sdbusplus::async::sleep_for(ctx, 1s) |
+              sdbusplus::async::execution::then([&]() { ctx.request_stop(); }));
+
+    ctx.run();
+}
+
+TEST_F(ModbusTest, TestReadHoldingRegisterSegmentedSuccess)
+{
+    ctx.spawn(TestHoldingRegisters(
+        TestIntf::testSuccessReadHoldingRegisterSegmentedOffset, true));
+
+    ctx.spawn(sdbusplus::async::sleep_for(ctx, 1s) |
+              sdbusplus::async::execution::then([&]() { ctx.request_stop(); }));
+
+    ctx.run();
+}
+
+TEST_F(ModbusTest, TestReadHoldingRegisterFailure)
+{
+    ctx.spawn(
+        TestHoldingRegisters(TestIntf::testFailureReadHoldingRegister, false));
+
+    ctx.spawn(sdbusplus::async::sleep_for(ctx, 1s) |
+              sdbusplus::async::execution::then([&]() { ctx.request_stop(); }));
+
+    ctx.run();
+}