// Package modbus is a minimal Modbus TCP client, written against the // MODBUS Application Protocol Specification V1.1b3 and the MODBUS Messaging on // TCP/IP Implementation Guide V1.0b rather than pulled in as a dependency: the // whole API server is stdlib-only, so this sits beside internal/mqtt's // hand-rolled MQTT and internal/ocpp's hand-rolled RFC 6455 WebSocket for the // same reason. // // It is deliberately scoped to what the Anker SOLIX V1 EV charger needs // (internal/plugins/builtin/ankersolix): read a contiguous block of holding // registers, and write one register to issue a command. Concretely that means: // // - Function codes 0x03 (Read Holding Registers) and 0x06 (Write Single // Register) only. The charger exposes its whole map — read-only and // read-write alike — in one 2xxxx address space, which is the holding // register convention. // - One request in flight at a time. Modbus TCP allows pipelining by // transaction identifier; nothing here needs it, and a strictly synchronous // exchange means a desynchronised peer cannot silently mismatch replies. // - No reconnect and no pooling. The charger accepts at most two simultaneous // clients (Anker's Modbus spec, §"Activate Modbus TCP"), one of which an // operator may well be using for a debugging tool, so a Client is meant to // be opened for one exchange and closed again rather than held open. // // A Client is safe for concurrent use; requests are serialized. package modbus import ( "context" "encoding/binary" "errors" "fmt" "io" "net" "sync" "time" ) // Defaults applied by Options.normalize when a field is left zero. const ( defaultUnitID = 1 defaultTimeout = 5 * time.Second defaultConnectTimeout = 5 * time.Second ) // ErrClosed is returned once the connection is gone. var ErrClosed = errors.New("modbus: connection closed") // Protocol constants. The MBAP header is seven bytes — transaction id (2), // protocol id (2, always zero for Modbus), length (2), unit id (1) — and is // followed by the PDU, whose first byte is the function code. const ( mbapLen = 7 protocolID = 0 fcReadHold = 0x03 fcWriteReg = 0x06 excMask = 0x80 // set on the echoed function code when the reply is an exception maxReadRegs = 125 // §6.3: the largest quantity one FC03 request may ask for ) // maxPDU bounds what this client will read back from a peer. The spec's own // ceiling is 253 bytes of PDU, so anything larger is a broken or hostile peer // and the stream can no longer be framed. const maxPDU = 253 // Options configures a connection. type Options struct { // Address is the charger's host:port. Required. Anker's Modbus TCP server // listens on port 502. Address string // UnitID addresses a device behind a gateway. The charger answers directly, // so this is 1 unless a deployment puts a bridge in the way. UnitID byte // Timeout bounds one request/response exchange. Timeout time.Duration // ConnectTimeout bounds the TCP dial. ConnectTimeout time.Duration } func (o *Options) normalize() { if o.UnitID == 0 { o.UnitID = defaultUnitID } if o.Timeout <= 0 { o.Timeout = defaultTimeout } if o.ConnectTimeout <= 0 { o.ConnectTimeout = defaultConnectTimeout } } // Error is a Modbus exception response: the server understood the frame and // refused it. It is distinct from a transport failure, because it says something // about the request (a bad address, an out-of-range value) rather than the link. type Error struct { Function byte Code byte } func (e *Error) Error() string { return fmt.Sprintf("modbus: server rejected function 0x%02x: %s", e.Function, exceptionText(e.Code)) } // exceptionText names the exception codes in §7 that this client can provoke. 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 (request accepted, still processing)" case 0x06: return "server device busy" case 0x0B: return "gateway target device failed to respond" default: return fmt.Sprintf("exception code 0x%02x", code) } } // Client is a connected Modbus TCP session. type Client struct { conn net.Conn unitID byte timeout time.Duration mu sync.Mutex // serializes exchanges; one request is in flight at a time nextTx uint16 closed bool } // Connect dials the server. The returned Client owns the connection; call Close // when done. func Connect(ctx context.Context, opt Options) (*Client, error) { opt.normalize() if opt.Address == "" { return nil, errors.New("modbus: server address is required") } d := net.Dialer{Timeout: opt.ConnectTimeout} conn, err := d.DialContext(ctx, "tcp", opt.Address) if err != nil { return nil, fmt.Errorf("modbus: cannot reach %s: %w", opt.Address, err) } return &Client{conn: conn, unitID: opt.UnitID, timeout: opt.Timeout}, nil } // Close releases the connection. It is safe to call more than once, which // matters because callers defer it and may also close on an error path. func (c *Client) Close() error { c.mu.Lock() defer c.mu.Unlock() if c.closed { return nil } c.closed = true return c.conn.Close() } // ReadHolding reads count consecutive holding registers starting at addr // (function code 0x03). Register values are returned in the order read. func (c *Client) ReadHolding(ctx context.Context, addr, count uint16) ([]uint16, error) { if count == 0 || count > maxReadRegs { return nil, fmt.Errorf("modbus: cannot read %d registers in one request (1-%d)", count, maxReadRegs) } req := make([]byte, 5) req[0] = fcReadHold binary.BigEndian.PutUint16(req[1:], addr) binary.BigEndian.PutUint16(req[3:], count) pdu, err := c.exchange(ctx, req) if err != nil { return nil, err } // Reply PDU: function code, byte count, then two bytes per register. if len(pdu) < 2 { return nil, errors.New("modbus: truncated read reply") } n := int(pdu[1]) if n != int(count)*2 || len(pdu) < 2+n { return nil, fmt.Errorf("modbus: read reply carries %d bytes, want %d", len(pdu)-2, int(count)*2) } out := make([]uint16, count) for i := range out { out[i] = binary.BigEndian.Uint16(pdu[2+i*2:]) } return out, nil } // WriteSingle writes one holding register (function code 0x06). The server // echoes the address and value it applied, which is verified here: a charger // that clamps a value reports the clamp rather than silently diverging. func (c *Client) WriteSingle(ctx context.Context, addr, value uint16) error { req := make([]byte, 5) req[0] = fcWriteReg binary.BigEndian.PutUint16(req[1:], addr) binary.BigEndian.PutUint16(req[3:], value) pdu, err := c.exchange(ctx, req) if err != nil { return err } if len(pdu) < 5 { return errors.New("modbus: truncated write reply") } if got := binary.BigEndian.Uint16(pdu[1:]); got != addr { return fmt.Errorf("modbus: write reply echoes register %d, want %d", got, addr) } if got := binary.BigEndian.Uint16(pdu[3:]); got != value { return fmt.Errorf("modbus: register %d accepted %d, not the %d requested", addr, got, value) } return nil } // exchange sends one PDU and returns the reply PDU, having checked the MBAP // header, the transaction identifier and the exception bit. func (c *Client) exchange(ctx context.Context, pdu []byte) ([]byte, error) { c.mu.Lock() defer c.mu.Unlock() if c.closed { return nil, ErrClosed } // A deadline drawn from both the context and the configured timeout, so a // cancelled caller and a mute peer are both bounded. deadline := time.Now().Add(c.timeout) if d, ok := ctx.Deadline(); ok && d.Before(deadline) { deadline = d } if err := c.conn.SetDeadline(deadline); err != nil { return nil, err } c.nextTx++ tx := c.nextTx frame := make([]byte, mbapLen+len(pdu)) binary.BigEndian.PutUint16(frame[0:], tx) binary.BigEndian.PutUint16(frame[2:], protocolID) // Length counts the unit id plus the PDU, per the messaging guide §3.1.3. binary.BigEndian.PutUint16(frame[4:], uint16(len(pdu)+1)) frame[6] = c.unitID copy(frame[mbapLen:], pdu) if _, err := c.conn.Write(frame); err != nil { return nil, fmt.Errorf("modbus: sending request: %w", err) } var header [mbapLen]byte if _, err := io.ReadFull(c.conn, header[:]); err != nil { return nil, fmt.Errorf("modbus: reading reply header: %w", err) } if got := binary.BigEndian.Uint16(header[2:]); got != protocolID { return nil, fmt.Errorf("modbus: reply has protocol id %d, want 0", got) } length := int(binary.BigEndian.Uint16(header[4:])) if length < 2 || length-1 > maxPDU { return nil, fmt.Errorf("modbus: reply announces %d bytes, which is not a valid PDU length", length) } body := make([]byte, length-1) // less the unit id, already in the header if _, err := io.ReadFull(c.conn, body); err != nil { return nil, fmt.Errorf("modbus: reading reply: %w", err) } if got := binary.BigEndian.Uint16(header[0:]); got != tx { // Synchronous exchange, so this is a desynchronised stream rather than a // late reply to something else; the connection is no longer trustworthy. return nil, fmt.Errorf("modbus: reply is for transaction %d, want %d", got, tx) } fc := body[0] if fc&excMask != 0 { if len(body) < 2 { return nil, errors.New("modbus: exception reply carries no code") } return nil, &Error{Function: fc &^ excMask, Code: body[1]} } if fc != pdu[0] { return nil, fmt.Errorf("modbus: reply is for function 0x%02x, want 0x%02x", fc, pdu[0]) } return body, nil }