diff --git a/README.md b/README.md index e952636..589ff8d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Supported formats ----------------- * TCP * Serial (RTU, ASCII) +* RTU over TCP (for serial-to-ethernet converters) Usage ----- @@ -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: @@ -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() diff --git a/rtuclient.go b/rtuclient.go index ad6a31a..0e96c7c 100644 --- a/rtuclient.go +++ b/rtuclient.go @@ -8,6 +8,10 @@ import ( "encoding/binary" "fmt" "io" + "log" + "net" + "strings" + "sync" "time" ) @@ -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 } @@ -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 { @@ -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() + } +} diff --git a/rtuclient_test.go b/rtuclient_test.go index 5a0bcc2..11e373a 100644 --- a/rtuclient_test.go +++ b/rtuclient_test.go @@ -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") + } +}