Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Supported formats
-----------------
* TCP
* Serial (RTU, ASCII)
* RTU over TCP (for serial-to-ethernet converters)

Usage
-----
Expand All @@ -37,6 +38,10 @@ results, err := client.ReadInputRegisters(8, 1)
// Default configuration is 19200, 8, 1, even
client = modbus.RTUClient("/dev/ttyS0")
results, err = client.ReadCoils(2, 1)

// Modbus RTU over TCP (for ser2sock/serial-to-ethernet converters)
client = modbus.RTUClient("tcp://localhost:4000")
results, err = client.ReadCoils(2, 1)
```

Advanced usage:
Expand All @@ -59,12 +64,27 @@ results, err = client.WriteMultipleCoils(5, 10, []byte{4, 3})
```go
// Modbus RTU/ASCII
handler := modbus.NewRTUClientHandler("/dev/ttyUSB0")
handler.BaudRate = 115200
handler.DataBits = 8
handler.Parity = "N"
handler.StopBits = 1
serialTransporter := handler.GetSerialTransporter()
serialTransporter.BaudRate = 115200
serialTransporter.DataBits = 8
serialTransporter.Parity = "N"
serialTransporter.StopBits = 1
handler.SlaveId = 1

err := handler.Connect()
defer handler.Close()

client := modbus.NewClient(handler)
results, err := client.ReadDiscreteInputs(15, 2)
```

```go
// Modbus RTU over TCP (for ser2sock/serial-to-ethernet converters)
handler := modbus.NewRTUClientHandler("tcp://localhost:4000")
handler.SlaveId = 1
handler.Timeout = 5 * time.Second
tcpTransporter := handler.GetTCPTransporter()
tcpTransporter.Timeout = 5 * time.Second
tcpTransporter.Logger = log.New(os.Stdout, "rtu-tcp: ", log.LstdFlags)

err := handler.Connect()
defer handler.Close()
Expand Down
221 changes: 213 additions & 8 deletions rtuclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import (
"encoding/binary"
"fmt"
"io"
"log"
"net"
"strings"
"sync"
"time"
)

Expand All @@ -21,15 +25,41 @@ const (
// RTUClientHandler implements Packager and Transporter interface.
type RTUClientHandler struct {
rtuPackager
rtuSerialTransporter
rtuTransporter
}

// rtuTransporter is an interface for RTU transport layer.
type rtuTransporter interface {
Transporter
Connect() error
Close() error
}

// NewRTUClientHandler allocates and initializes a RTUClientHandler.
// It supports both serial and TCP addresses:
// - Serial: "/dev/ttyUSB0" or "COM1"
// - TCP: "tcp://localhost:4000"
func NewRTUClientHandler(address string) *RTUClientHandler {
handler := &RTUClientHandler{}
handler.Address = address
handler.Timeout = serialTimeout
handler.IdleTimeout = serialIdleTimeout

// Check if address is TCP
if strings.HasPrefix(address, "tcp://") {
tcpAddr := strings.TrimPrefix(address, "tcp://")
tcpTransporter := &rtuTCPTransporter{
Address: tcpAddr,
Timeout: serialTimeout,
IdleTimeout: serialIdleTimeout,
}
handler.rtuTransporter = tcpTransporter
} else {
// Serial transport
serialTransporter := &rtuSerialTransporter{}
serialTransporter.Address = address
serialTransporter.Timeout = serialTimeout
serialTransporter.IdleTimeout = serialIdleTimeout
handler.rtuTransporter = serialTransporter
}

return handler
}

Expand All @@ -39,16 +69,45 @@ func RTUClient(address string) Client {
return NewClient(handler)
}

// Connect connects to the RTU device (serial or TCP).
func (h *RTUClientHandler) Connect() error {
return h.rtuTransporter.Connect()
}

// Close closes the RTU connection.
func (h *RTUClientHandler) Close() error {
return h.rtuTransporter.Close()
}

// GetSerialTransporter returns the underlying serial transporter if this is a serial connection.
// Returns nil if this is a TCP connection.
func (h *RTUClientHandler) GetSerialTransporter() *rtuSerialTransporter {
if t, ok := h.rtuTransporter.(*rtuSerialTransporter); ok {
return t
}
return nil
}

// GetTCPTransporter returns the underlying TCP transporter if this is a TCP connection.
// Returns nil if this is a serial connection.
func (h *RTUClientHandler) GetTCPTransporter() *rtuTCPTransporter {
if t, ok := h.rtuTransporter.(*rtuTCPTransporter); ok {
return t
}
return nil
}

// rtuPackager implements Packager interface.
type rtuPackager struct {
SlaveId byte
}

// Encode encodes PDU in a RTU frame:
// Slave Address : 1 byte
// Function : 1 byte
// Data : 0 up to 252 bytes
// CRC : 2 byte
//
// Slave Address : 1 byte
// Function : 1 byte
// Data : 0 up to 252 bytes
// CRC : 2 byte
func (mb *rtuPackager) Encode(pdu *ProtocolDataUnit) (adu []byte, err error) {
length := len(pdu.Data) + 4
if length > rtuMaxSize {
Expand Down Expand Up @@ -208,3 +267,149 @@ func calculateResponseLength(adu []byte) int {
}
return length
}

// rtuTCPTransporter implements Transporter interface for RTU over TCP.
type rtuTCPTransporter struct {
Address string
Timeout time.Duration
IdleTimeout time.Duration
Logger *log.Logger

mu sync.Mutex
conn net.Conn
closeTimer *time.Timer
lastActivity time.Time
}

func (mb *rtuTCPTransporter) Send(aduRequest []byte) (aduResponse []byte, err error) {
mb.mu.Lock()
defer mb.mu.Unlock()

// Make sure connection is established
if err = mb.connect(); err != nil {
return
}
// Start the timer to close when idle
mb.lastActivity = time.Now()
mb.startCloseTimer()

// Set read/write timeout
var timeout time.Time
if mb.Timeout > 0 {
timeout = mb.lastActivity.Add(mb.Timeout)
}
if err = mb.conn.SetDeadline(timeout); err != nil {
return
}

// Send the request
mb.logf("modbus: sending % x\n", aduRequest)
if _, err = mb.conn.Write(aduRequest); err != nil {
return
}

function := aduRequest[1]
functionFail := aduRequest[1] & 0x80
bytesToRead := calculateResponseLength(aduRequest)

var n int
var n1 int
var data [rtuMaxSize]byte
// We first read the minimum length and then read either the full package
// or the error package, depending on the error status (byte 2 of the response)
n, err = io.ReadAtLeast(mb.conn, data[:], rtuMinSize)
if err != nil {
return
}
// if the function is correct
if data[1] == function {
// we read the rest of the bytes
if n < bytesToRead {
if bytesToRead > rtuMinSize && bytesToRead <= rtuMaxSize {
if bytesToRead > n {
n1, err = io.ReadFull(mb.conn, data[n:bytesToRead])
n += n1
}
}
}
} else if data[1] == functionFail {
// for error we need to read 5 bytes
if n < rtuExceptionSize {
n1, err = io.ReadFull(mb.conn, data[n:rtuExceptionSize])
}
n += n1
}

if err != nil {
return
}
aduResponse = data[:n]
mb.logf("modbus: received % x\n", aduResponse)
return
}

func (mb *rtuTCPTransporter) Connect() error {
mb.mu.Lock()
defer mb.mu.Unlock()

return mb.connect()
}

func (mb *rtuTCPTransporter) connect() error {
if mb.conn == nil {
dialer := net.Dialer{Timeout: mb.Timeout}
conn, err := dialer.Dial("tcp", mb.Address)
if err != nil {
return err
}
mb.conn = conn
}
return nil
}

func (mb *rtuTCPTransporter) Close() error {
mb.mu.Lock()
defer mb.mu.Unlock()

return mb.close()
}

func (mb *rtuTCPTransporter) close() error {
if mb.conn != nil {
err := mb.conn.Close()
mb.conn = nil
return err
}
return nil
}

func (mb *rtuTCPTransporter) logf(format string, v ...interface{}) {
if mb.Logger != nil {
mb.Logger.Printf(format, v...)
}
}

func (mb *rtuTCPTransporter) startCloseTimer() {
if mb.IdleTimeout <= 0 {
return
}
if mb.closeTimer == nil {
mb.closeTimer = time.AfterFunc(mb.IdleTimeout, mb.closeIdle)
} else {
mb.closeTimer.Reset(mb.IdleTimeout)
}
}

func (mb *rtuTCPTransporter) closeIdle() {
mb.mu.Lock()
defer mb.mu.Unlock()

if mb.IdleTimeout <= 0 {
return
}
idle := time.Now().Sub(mb.lastActivity)
if idle >= mb.IdleTimeout {
mb.logf("modbus: closing connection due to idle timeout: %v", idle)
mb.close()
}
}
49 changes: 49 additions & 0 deletions rtuclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,52 @@ func BenchmarkRTUDecoder(b *testing.B) {
}
}
}

func TestRTUTCPHandlerCreation(t *testing.T) {
handler := NewRTUClientHandler("tcp://localhost:4000")

// Verify TCP transporter was created
tcpTransporter := handler.GetTCPTransporter()
if tcpTransporter == nil {
t.Fatal("Expected TCP transporter, got nil")
}

// Verify serial transporter is nil
serialTransporter := handler.GetSerialTransporter()
if serialTransporter != nil {
t.Fatal("Expected nil serial transporter for TCP address")
}

// Verify address was parsed correctly
if tcpTransporter.Address != "localhost:4000" {
t.Fatalf("Expected address 'localhost:4000', got '%s'", tcpTransporter.Address)
}
}

func TestRTUSerialHandlerCreation(t *testing.T) {
handler := NewRTUClientHandler("/dev/ttyUSB0")

// Verify serial transporter was created
serialTransporter := handler.GetSerialTransporter()
if serialTransporter == nil {
t.Fatal("Expected serial transporter, got nil")
}

// Verify TCP transporter is nil
tcpTransporter := handler.GetTCPTransporter()
if tcpTransporter != nil {
t.Fatal("Expected nil TCP transporter for serial address")
}

// Verify address was set correctly
if serialTransporter.Address != "/dev/ttyUSB0" {
t.Fatalf("Expected address '/dev/ttyUSB0', got '%s'", serialTransporter.Address)
}
}

func TestRTUClientWithTCP(t *testing.T) {
client := RTUClient("tcp://192.168.1.100:502")
if client == nil {
t.Fatal("Expected client, got nil")
}
}