Reach the charger where it is, instead of waiting for it to call

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>
This commit is contained in:
tajniak81
2026-09-01 17:03:27 +02:00
co-authored by Claude Opus 5
parent e9a82a1cca
commit f7472bada3
13 changed files with 1658 additions and 47 deletions
+284
View File
@@ -0,0 +1,284 @@
// 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
}
+284
View File
@@ -0,0 +1,284 @@
package modbus
import (
"context"
"encoding/binary"
"errors"
"io"
"net"
"testing"
)
// serve runs a one-connection fake Modbus server. handle receives the request
// PDU and returns the reply PDU; returning nil closes the connection instead,
// which is how a mute or dying peer is simulated. The request frames the server
// saw are sent back on the returned channel once the connection ends.
func serve(t *testing.T, handle func(pdu []byte) []byte) (addr string, seen <-chan [][]byte) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
frames := make(chan [][]byte, 1)
go func() {
var got [][]byte
defer func() { frames <- got }()
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
for {
var header [mbapLen]byte
if _, err := io.ReadFull(conn, header[:]); err != nil {
return
}
length := int(binary.BigEndian.Uint16(header[4:]))
body := make([]byte, length-1)
if _, err := io.ReadFull(conn, body); err != nil {
return
}
got = append(got, append(append([]byte{}, header[:]...), body...))
reply := handle(body)
if reply == nil {
return
}
out := make([]byte, mbapLen+len(reply))
copy(out[0:], header[0:2]) // echo the transaction id
binary.BigEndian.PutUint16(out[2:], protocolID)
binary.BigEndian.PutUint16(out[4:], uint16(len(reply)+1))
out[6] = header[6]
copy(out[mbapLen:], reply)
if _, err := conn.Write(out); err != nil {
return
}
}
}()
return ln.Addr().String(), frames
}
func dial(t *testing.T, addr string) *Client {
t.Helper()
c, err := Connect(context.Background(), Options{Address: addr})
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c
}
// readReply builds a well-formed FC03 reply carrying the given register values.
func readReply(values ...uint16) []byte {
out := []byte{fcReadHold, byte(len(values) * 2)}
for _, v := range values {
out = binary.BigEndian.AppendUint16(out, v)
}
return out
}
func TestReadHoldingDecodesRegisters(t *testing.T) {
addr, seen := serve(t, func(pdu []byte) []byte {
return readReply(0x0102, 0x0304, 0xFFFF)
})
c := dial(t, addr)
got, err := c.ReadHolding(context.Background(), 20053, 3)
if err != nil {
t.Fatalf("ReadHolding: %v", err)
}
want := []uint16{0x0102, 0x0304, 0xFFFF}
if len(got) != len(want) {
t.Fatalf("got %d registers, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("register %d = 0x%04x, want 0x%04x", i, got[i], want[i])
}
}
_ = c.Close()
frames := <-seen
if len(frames) != 1 {
t.Fatalf("server saw %d frames, want 1", len(frames))
}
f := frames[0]
if pid := binary.BigEndian.Uint16(f[2:]); pid != protocolID {
t.Errorf("protocol id = %d, want 0", pid)
}
// Length counts the unit id plus the five-byte PDU.
if l := binary.BigEndian.Uint16(f[4:]); l != 6 {
t.Errorf("length field = %d, want 6", l)
}
if f[6] != defaultUnitID {
t.Errorf("unit id = %d, want %d", f[6], defaultUnitID)
}
if f[mbapLen] != fcReadHold {
t.Errorf("function code = 0x%02x, want 0x%02x", f[mbapLen], fcReadHold)
}
if a := binary.BigEndian.Uint16(f[mbapLen+1:]); a != 20053 {
t.Errorf("address = %d, want 20053", a)
}
if n := binary.BigEndian.Uint16(f[mbapLen+3:]); n != 3 {
t.Errorf("quantity = %d, want 3", n)
}
}
func TestReadHoldingRejectsBadCounts(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte { return readReply(0) })
c := dial(t, addr)
for _, count := range []uint16{0, maxReadRegs + 1} {
if _, err := c.ReadHolding(context.Background(), 20000, count); err == nil {
t.Errorf("count %d was accepted, want a refusal before the wire", count)
}
}
}
func TestReadHoldingRejectsShortPayload(t *testing.T) {
// The server claims three registers but sends two.
addr, _ := serve(t, func(pdu []byte) []byte {
return []byte{fcReadHold, 6, 0x00, 0x01, 0x00, 0x02}
})
c := dial(t, addr)
if _, err := c.ReadHolding(context.Background(), 20000, 3); err == nil {
t.Fatal("a truncated payload was accepted")
}
}
func TestWriteSingleEchoesAddressAndValue(t *testing.T) {
addr, seen := serve(t, func(pdu []byte) []byte {
return append([]byte{fcWriteReg}, pdu[1:5]...) // echo address + value
})
c := dial(t, addr)
if err := c.WriteSingle(context.Background(), 21000, 1); err != nil {
t.Fatalf("WriteSingle: %v", err)
}
_ = c.Close()
frames := <-seen
if len(frames) != 1 {
t.Fatalf("server saw %d frames, want 1", len(frames))
}
f := frames[0]
if f[mbapLen] != fcWriteReg {
t.Errorf("function code = 0x%02x, want 0x%02x", f[mbapLen], fcWriteReg)
}
if a := binary.BigEndian.Uint16(f[mbapLen+1:]); a != 21000 {
t.Errorf("address = %d, want 21000", a)
}
if v := binary.BigEndian.Uint16(f[mbapLen+3:]); v != 1 {
t.Errorf("value = %d, want 1", v)
}
}
// A charger that clamps a written value reports the clamp. That must surface as
// an error rather than be mistaken for the value having been applied.
func TestWriteSingleRejectsClampedValue(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte {
reply := append([]byte{fcWriteReg}, pdu[1:5]...)
binary.BigEndian.PutUint16(reply[3:], 160) // clamped to 16.0 A
return reply
})
c := dial(t, addr)
err := c.WriteSingle(context.Background(), 21001, 320)
if err == nil {
t.Fatal("a clamped write was reported as successful")
}
}
func TestWriteSingleRejectsWrongAddressEcho(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte {
reply := append([]byte{fcWriteReg}, pdu[1:5]...)
binary.BigEndian.PutUint16(reply[1:], 29999)
return reply
})
c := dial(t, addr)
if err := c.WriteSingle(context.Background(), 21000, 1); err == nil {
t.Fatal("a write echoing the wrong register was accepted")
}
}
func TestExceptionReplyBecomesError(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte {
return []byte{fcReadHold | excMask, 0x02} // illegal data address
})
c := dial(t, addr)
_, err := c.ReadHolding(context.Background(), 29999, 1)
var mbErr *Error
if !errors.As(err, &mbErr) {
t.Fatalf("error = %v, want a *modbus.Error", err)
}
if mbErr.Function != fcReadHold {
t.Errorf("Function = 0x%02x, want 0x%02x", mbErr.Function, fcReadHold)
}
if mbErr.Code != 0x02 {
t.Errorf("Code = 0x%02x, want 0x02", mbErr.Code)
}
}
// A reply carrying someone else's transaction id means the stream is out of
// step, so it must not be handed back as this request's answer.
func TestMismatchedTransactionIDIsRejected(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
var header [mbapLen]byte
if _, err := io.ReadFull(conn, header[:]); err != nil {
return
}
body := make([]byte, int(binary.BigEndian.Uint16(header[4:]))-1)
if _, err := io.ReadFull(conn, body); err != nil {
return
}
reply := readReply(1)
out := make([]byte, mbapLen+len(reply))
binary.BigEndian.PutUint16(out[0:], binary.BigEndian.Uint16(header[0:])+7) // wrong
binary.BigEndian.PutUint16(out[2:], protocolID)
binary.BigEndian.PutUint16(out[4:], uint16(len(reply)+1))
out[6] = header[6]
copy(out[mbapLen:], reply)
_, _ = conn.Write(out)
}()
c := dial(t, ln.Addr().String())
if _, err := c.ReadHolding(context.Background(), 20000, 1); err == nil {
t.Fatal("a reply for another transaction was accepted")
}
}
func TestClosedClientRefusesRequests(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte { return readReply(0) })
c := dial(t, addr)
if err := c.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if _, err := c.ReadHolding(context.Background(), 20000, 1); !errors.Is(err, ErrClosed) {
t.Fatalf("error = %v, want ErrClosed", err)
}
// Close is deferred by callers and may also run on an error path.
if err := c.Close(); err != nil {
t.Fatalf("second Close: %v", err)
}
}
func TestConnectRequiresAddress(t *testing.T) {
if _, err := Connect(context.Background(), Options{}); err == nil {
t.Fatal("an empty address was accepted")
}
}