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/rtu/meson.build b/rtu/meson.build
index fb19994..e088f17 100644
--- a/rtu/meson.build
+++ b/rtu/meson.build
@@ -1,6 +1,10 @@
modbus_rtu_lib = static_library(
'modbus_rtu_lib',
- ['modbus/modbus_commands.cpp', 'modbus/modbus_message.cpp'],
+ [
+ 'modbus/modbus.cpp',
+ 'modbus/modbus_commands.cpp',
+ 'modbus/modbus_message.cpp',
+ ],
include_directories: ['.'],
dependencies: [default_deps],
)
diff --git a/rtu/modbus/modbus.cpp b/rtu/modbus/modbus.cpp
new file mode 100644
index 0000000..78a5b73
--- /dev/null
+++ b/rtu/modbus/modbus.cpp
@@ -0,0 +1,245 @@
+#include "modbus.hpp"
+
+#include "modbus_commands.hpp"
+
+#include <termios.h>
+
+#include <phosphor-logging/lg2.hpp>
+#include <sdbusplus/async.hpp>
+
+namespace phosphor::modbus::rtu
+{
+
+PHOSPHOR_LOG2_USING;
+
+const std::unordered_map<int, speed_t> baudRateMap = {
+ {0, B0}, {50, B50}, {75, B75}, {110, B110},
+ {134, B134}, {150, B150}, {200, B200}, {300, B300},
+ {600, B600}, {1200, B1200}, {1800, B1800}, {2400, B2400},
+ {4800, B4800}, {9600, B9600}, {19200, B19200}, {38400, B38400},
+ {57600, B57600}, {115200, B115200}};
+
+Modbus::Modbus(sdbusplus::async::context& ctx, int fd, uint32_t baudRate,
+ uint16_t rtsDelay) :
+ ctx(ctx), fd(fd), rtsDelay(rtsDelay), fdioInstance(ctx, fd)
+{
+ if (!setProperties(baudRate, Parity::even))
+ {
+ throw std::runtime_error("Failed to set port properties");
+ }
+
+ info("Modbus created successfully");
+}
+
+static auto applyParitySettings(Parity parity, termios& tty) -> bool
+{
+ switch (parity)
+ {
+ case Parity::none:
+ tty.c_cflag &= ~PARENB;
+ tty.c_iflag &= ~INPCK;
+ break;
+ case Parity::odd:
+ tty.c_cflag |= PARENB;
+ tty.c_cflag |= PARODD;
+ tty.c_iflag |= INPCK;
+ break;
+ case Parity::even:
+ tty.c_cflag |= PARENB;
+ tty.c_cflag &= ~PARODD;
+ tty.c_iflag |= INPCK;
+ break;
+ default:
+ error("Invalid parity");
+ return false;
+ }
+
+ return true;
+}
+
+auto Modbus::setProperties(uint32_t inBaudRate, Parity inParity) -> bool
+{
+ if (inBaudRate == baudRate && inParity == parity)
+ {
+ return true;
+ }
+
+ termios tty;
+ if (tcgetattr(fd, &tty) != 0)
+ {
+ error("Error getting termios");
+ return false;
+ }
+
+ if (inBaudRate != baudRate)
+ {
+ if (cfsetspeed(&tty, baudRateMap.at(inBaudRate)) != 0)
+ {
+ error("Error setting baud rate");
+ return false;
+ }
+ }
+
+ if (inParity != parity)
+ {
+ if (!applyParitySettings(inParity, tty))
+ {
+ error("Invalid parity");
+ return false;
+ }
+ }
+
+ // TODO: We might not need these again.
+ tty.c_cflag |= CS8 | CLOCAL | CREAD;
+ // Set non-blocking read behavior
+ tty.c_cc[VMIN] = 1; // Minimum characters to read
+ tty.c_cc[VTIME] = 0; // Timeout in deciseconds (0 for no timeout)
+
+ if (tcsetattr(fd, TCSAFLUSH, &tty) != 0)
+ {
+ error("Error setting termios");
+ return false;
+ }
+
+ parity = inParity;
+ baudRate = inBaudRate;
+
+ debug("Properties set successfully");
+
+ return true;
+}
+
+static auto printMessage(uint8_t* data, size_t len) -> void
+{
+ std::stringstream ss;
+ ss << std::hex << std::setfill('0');
+
+ for (size_t i = 0; i < len; ++i)
+ {
+ ss << std::setw(2) << static_cast<int>(data[i]) << " ";
+ }
+
+ debug("{MSG}", "MSG", ss.str());
+}
+
+auto Modbus::readHoldingRegisters(uint8_t deviceAddress,
+ uint16_t registerOffset,
+ std::vector<uint16_t>& registers)
+ -> sdbusplus::async::task<bool>
+{
+ try
+ {
+ ReadHoldingRegistersRequest request(deviceAddress, registerOffset,
+ registers.size());
+ ReadHoldingRegistersResponse response(deviceAddress, registers);
+
+ request.encode();
+
+ debug(
+ "Sending read holding registers request for {REGISTER_OFFSET} {DEVICE_ADDRESS}",
+ "REGISTER_OFFSET", registerOffset, "DEVICE_ADDRESS", deviceAddress);
+
+ if (!co_await writeRequest(deviceAddress, request))
+ {
+ co_return false;
+ }
+
+ debug(
+ "Waiting for read holding registers response for {REGISTER_OFFSET} {DEVICE_ADDRESS}",
+ "REGISTER_OFFSET", registerOffset, "DEVICE_ADDRESS", deviceAddress);
+
+ if (!co_await readResponse(deviceAddress, response,
+ request.functionCode))
+ {
+ co_return false;
+ }
+
+ response.decode();
+ }
+ catch (std::exception& e)
+ {
+ error(
+ "Failed to read holding registers for {DEVICE_ADDRESS} with {ERROR}",
+ "DEVICE_ADDRESS", deviceAddress, "ERROR", e);
+ co_return false;
+ }
+
+ co_return true;
+}
+
+auto Modbus::writeRequest(uint8_t deviceAddress, Message& request)
+ -> sdbusplus::async::task<bool>
+{
+ printMessage(request.raw.data(), request.len);
+
+ // Flush the input & output buffers for the fd
+ tcflush(fd, TCIOFLUSH);
+ auto ret = write(fd, request.raw.data(), request.len);
+ if ((size_t)ret != request.len)
+ {
+ error("Failed to send request to device {DEVICE_ADDRESS} with {ERROR}",
+ "DEVICE_ADDRESS", deviceAddress, "ERROR", strerror(errno));
+ co_return false;
+ }
+
+ co_return true;
+}
+
+auto Modbus::readResponse(uint8_t deviceAddress, Message& response,
+ uint8_t expectedResponseCode)
+ -> sdbusplus::async::task<bool>
+{
+ int expectedLen = response.len;
+
+ do
+ {
+ debug("Waiting for response for {DEVICE_ADDRESS} with {EXPECTED} bytes",
+ "DEVICE_ADDRESS", deviceAddress, "EXPECTED", expectedLen);
+ co_await fdioInstance.next();
+ // TODO: Handle FD timeout in case of no response
+ auto ret = read(fd, response.raw.data() + response.len - expectedLen,
+ expectedLen);
+ if (ret < 0)
+ {
+ error(
+ "Failed to read response for device {DEVICE_ADDRESS} with {ERROR}",
+ "DEVICE_ADDRESS", deviceAddress, "ERROR", strerror(errno));
+ co_return false;
+ }
+
+ debug("Received response for {DEVICE_ADDRESS} with {SIZE}",
+ "DEVICE_ADDRESS", deviceAddress, "SIZE", ret);
+
+ printMessage(response.raw.data() + response.len - expectedLen, ret);
+
+ expectedLen -= ret;
+
+ if (expectedLen > 0)
+ {
+ debug(
+ "Read response for device {DEVICE_ADDRESS} with {EXPECTED} bytes remaining",
+ "DEVICE_ADDRESS", deviceAddress, "EXPECTED", expectedLen);
+ }
+ if (ret >= 2 && response.functionCode != expectedResponseCode)
+ {
+ // Update the length of the expected response to received error
+ // message size
+ response.len = ret;
+ error("Received error response {CODE} for device {DEVICE_ADDRESS}",
+ "CODE", response.raw[1], "DEVICE_ADDRESS", deviceAddress);
+ co_return false;
+ }
+ } while (expectedLen > 0);
+
+ if (rtsDelay)
+ {
+ // Asynchronously sleep for rts_delay milliseconds in case bus needs
+ // to be idle after each transaction
+ co_await sdbusplus::async::sleep_for(
+ ctx, std::chrono::milliseconds(rtsDelay));
+ }
+
+ co_return true;
+}
+
+} // namespace phosphor::modbus::rtu
diff --git a/rtu/modbus/modbus.hpp b/rtu/modbus/modbus.hpp
new file mode 100644
index 0000000..d26bed9
--- /dev/null
+++ b/rtu/modbus/modbus.hpp
@@ -0,0 +1,46 @@
+#pragma once
+
+#include <sdbusplus/async.hpp>
+
+namespace phosphor::modbus::rtu
+{
+
+class Message;
+
+enum class Parity
+{
+ odd,
+ even,
+ none,
+ unknown
+};
+
+class Modbus
+{
+ public:
+ explicit Modbus(sdbusplus::async::context& ctx, int fd, uint32_t baudRate,
+ uint16_t rtsDelay);
+
+ auto setProperties(uint32_t inBaudRate, Parity inParity) -> bool;
+
+ auto readHoldingRegisters(uint8_t deviceAddress, uint16_t registerOffset,
+ std::vector<uint16_t>& registers)
+ -> sdbusplus::async::task<bool>;
+
+ private:
+ auto writeRequest(uint8_t deviceAddress, Message& request)
+ -> sdbusplus::async::task<bool>;
+
+ auto readResponse(uint8_t deviceAddress, Message& response,
+ uint8_t expectedResponseCode)
+ -> sdbusplus::async::task<bool>;
+
+ sdbusplus::async::context& ctx;
+ int fd;
+ uint16_t rtsDelay;
+ uint32_t baudRate = 0;
+ Parity parity = Parity::odd;
+ sdbusplus::async::fdio fdioInstance;
+};
+
+} // namespace phosphor::modbus::rtu
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();
+}