Files
uopi/internal/datasource/modbus/client.go
T
2026-06-24 06:12:52 +02:00

217 lines
5.6 KiB
Go

package modbus
import (
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"time"
)
// Modbus function codes.
const (
fcReadCoils = 0x01
fcReadDiscrete = 0x02
fcReadHolding = 0x03
fcReadInput = 0x04
fcWriteSingleCoil = 0x05
fcWriteSingleReg = 0x06
fcWriteMultipleRegs = 0x10
)
// client is a minimal Modbus TCP master for a single device address. Requests
// are serialised by mu (Modbus TCP is request/response and the connection is
// shared by all of a device's polled registers). The connection is dialled
// lazily and dropped on any I/O error so the next request reconnects.
type client struct {
addr string
timeout time.Duration
mu sync.Mutex
conn net.Conn
txID uint16
}
func newClient(addr string, timeout time.Duration) *client {
if timeout <= 0 {
timeout = 3 * time.Second
}
return &client{addr: addr, timeout: timeout}
}
func (c *client) close() {
c.mu.Lock()
c.closeLocked()
c.mu.Unlock()
}
func (c *client) closeLocked() {
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
}
// request sends a PDU to unitID and returns the response PDU (function code +
// data). It dials on demand and tears the connection down on error.
func (c *client) request(unitID byte, pdu []byte) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
conn, err := net.DialTimeout("tcp", c.addr, c.timeout)
if err != nil {
return nil, fmt.Errorf("modbus: dial %s: %w", c.addr, err)
}
c.conn = conn
}
resp, err := c.transact(unitID, pdu)
if err != nil {
c.closeLocked()
return nil, err
}
return resp, nil
}
// transact performs one MBAP-framed exchange. Caller holds mu.
func (c *client) transact(unitID byte, pdu []byte) ([]byte, error) {
c.txID++
tx := c.txID
frame := make([]byte, 7+len(pdu))
binary.BigEndian.PutUint16(frame[0:], tx) // transaction id
binary.BigEndian.PutUint16(frame[2:], 0) // protocol id (0 = Modbus)
binary.BigEndian.PutUint16(frame[4:], uint16(1+len(pdu))) // length: unit id + PDU
frame[6] = unitID
copy(frame[7:], pdu)
_ = c.conn.SetDeadline(time.Now().Add(c.timeout))
if _, err := c.conn.Write(frame); err != nil {
return nil, fmt.Errorf("modbus: write: %w", err)
}
head := make([]byte, 7)
if _, err := io.ReadFull(c.conn, head); err != nil {
return nil, fmt.Errorf("modbus: read header: %w", err)
}
if binary.BigEndian.Uint16(head[0:]) != tx {
return nil, fmt.Errorf("modbus: transaction id mismatch")
}
length := binary.BigEndian.Uint16(head[4:])
if length < 2 { // unit id + at least a function code
return nil, fmt.Errorf("modbus: short frame length %d", length)
}
body := make([]byte, length-1) // header already consumed the unit id
if _, err := io.ReadFull(c.conn, body); err != nil {
return nil, fmt.Errorf("modbus: read body: %w", err)
}
fc := body[0]
if fc&0x80 != 0 { // exception response
var ex byte
if len(body) >= 2 {
ex = body[1]
}
return nil, fmt.Errorf("modbus: exception 0x%02x (%s)", ex, exceptionText(ex))
}
return body, nil
}
// readRegisters reads `quantity` 16-bit registers via fc (holding or input).
func (c *client) readRegisters(unitID, fc byte, addr, quantity uint16) ([]uint16, error) {
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
resp, err := c.request(unitID, pdu)
if err != nil {
return nil, err
}
if len(resp) < 2 {
return nil, fmt.Errorf("modbus: short register response")
}
byteCount := int(resp[1])
if byteCount != int(quantity)*2 || len(resp) < 2+byteCount {
return nil, fmt.Errorf("modbus: register byte count %d (want %d)", byteCount, quantity*2)
}
regs := make([]uint16, quantity)
for i := range regs {
regs[i] = binary.BigEndian.Uint16(resp[2+i*2:])
}
return regs, nil
}
// readBits reads `quantity` bits via fc (coils or discrete inputs).
func (c *client) readBits(unitID, fc byte, addr, quantity uint16) ([]bool, error) {
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
resp, err := c.request(unitID, pdu)
if err != nil {
return nil, err
}
if len(resp) < 2 {
return nil, fmt.Errorf("modbus: short bit response")
}
byteCount := int(resp[1])
if len(resp) < 2+byteCount {
return nil, fmt.Errorf("modbus: bit byte count %d exceeds frame", byteCount)
}
bits := make([]bool, quantity)
for i := range bits {
idx := 2 + i/8
if idx >= len(resp) {
break
}
bits[i] = resp[idx]&(1<<(uint(i)%8)) != 0
}
return bits, nil
}
func (c *client) writeSingleRegister(unitID byte, addr, value uint16) error {
pdu := []byte{fcWriteSingleReg, byte(addr >> 8), byte(addr), byte(value >> 8), byte(value)}
_, err := c.request(unitID, pdu)
return err
}
func (c *client) writeMultipleRegisters(unitID byte, addr uint16, values []uint16) error {
pdu := make([]byte, 6+len(values)*2)
pdu[0] = fcWriteMultipleRegs
binary.BigEndian.PutUint16(pdu[1:], addr)
binary.BigEndian.PutUint16(pdu[3:], uint16(len(values)))
pdu[5] = byte(len(values) * 2)
for i, v := range values {
binary.BigEndian.PutUint16(pdu[6+i*2:], v)
}
_, err := c.request(unitID, pdu)
return err
}
func (c *client) writeSingleCoil(unitID byte, addr uint16, on bool) error {
var v uint16
if on {
v = 0xFF00
}
pdu := []byte{fcWriteSingleCoil, byte(addr >> 8), byte(addr), byte(v >> 8), byte(v)}
_, err := c.request(unitID, pdu)
return err
}
func exceptionText(code byte) string {
switch code {
case 0x01:
return "illegal function"
case 0x02:
return "illegal data address"
case 0x03:
return "illegal data value"
case 0x04:
return "server device failure"
case 0x05:
return "acknowledge"
case 0x06:
return "server device busy"
case 0x0B:
return "gateway target failed to respond"
default:
return "unknown"
}
}