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" } }