Modbus mode never returned a reading: every status poll came back as "the charger did not answer", though the charger was answering all along. It was refusing the question. The A5191 splits its map across two tables where the spec's single 2xxxx column suggests one — 20000-20100 are input registers and reject FC03 with an illegal-address exception at every address in the range, while 21000-21005 really are holding registers and read back over FC03. We inferred one space from the spec's layout and asked for all of it with FC03. The client learns FC04, sharing a body with FC03 since the two differ only in which table the server consults, and the plugin's two measurement reads move to it. Writes stay on FC06, where the controls already live. Confirmed against an A5191 on firmware 1.0.6.1: identity, live block and the control registers all decode as the spec tabulates them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
300 lines
10 KiB
Go
300 lines
10 KiB
Go
// 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), 0x04 (Read Input Registers)
|
|
// and 0x06 (Write Single Register) only. The charger splits its map across
|
|
// the first two: everything it measures or reports (20000-20100) answers on
|
|
// FC04 and rejects FC03 with an illegal-address exception, while the
|
|
// controls it accepts (21000-21005) are holding registers.
|
|
// - 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
|
|
fcReadInput = 0x04
|
|
fcWriteReg = 0x06
|
|
excMask = 0x80 // set on the echoed function code when the reply is an exception
|
|
maxReadRegs = 125 // §6.3/§6.4: the most registers one read 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) {
|
|
return c.readRegisters(ctx, fcReadHold, addr, count)
|
|
}
|
|
|
|
// ReadInput reads count consecutive input registers starting at addr (function
|
|
// code 0x04). It is the read the Anker charger's measurement block answers:
|
|
// the same request shape as ReadHolding, a different table on the device.
|
|
func (c *Client) ReadInput(ctx context.Context, addr, count uint16) ([]uint16, error) {
|
|
return c.readRegisters(ctx, fcReadInput, addr, count)
|
|
}
|
|
|
|
// readRegisters is the body both reads share; FC03 and FC04 differ only in
|
|
// which table the server looks the addresses up in, not in their framing.
|
|
func (c *Client) readRegisters(ctx context.Context, fc byte, 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] = fc
|
|
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
|
|
}
|