OCPP asks the charger to dial us: a public endpoint, a TLS certificate, and a route in through the customer's router. Our own handler then demanded two more things the V1 does not offer — TLS on a charger that connects over ws://, and Basic auth credentials the Anker app has no field for — so every connection was turned away before the upgrade. Anker publishes a Modbus TCP register map for this charger, and it inverts the problem: we dial the charger, on its own network, with no inbound reachability to arrange. That works for a charger behind a router that OCPP cannot reach at all. internal/modbus is the protocol, hand-rolled against the spec like the MQTT and WebSocket clients beside it. The plugin's modbus.go is the V1's map: the same 0-8 status enum the cloud already reports, per-phase measurements, and the writable registers behind start, stop, current limit, boost and phase mode. A new "modbus" control mode routes the existing control endpoints down it, so the REST surface, the rate limit, the confirmation step and the audit trail are the ones already there. The commands the register map has no equivalent for say so by name rather than failing as unknown, and a current below the charger's 6 A floor is refused because it pauses the charge rather than slowing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
285 lines
9.4 KiB
Go
285 lines
9.4 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) 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
|
|
}
|