Files
DriverVault/API Server/internal/modbus/client_test.go
T
tajniak81andClaude Opus 5 f7472bada3 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>
2026-09-01 17:03:27 +02:00

285 lines
8.0 KiB
Go

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