Files
DriverVault/API Server/internal/modbus/client_test.go
T
tajniak81andClaude Opus 5 cf4fd14b56 The table the charger actually keeps its measurements in
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>
2026-09-01 18:42:39 +02:00

331 lines
9.5 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 { return replyFor(fcReadHold, values...) }
// inputReply is the same for FC04, which frames its reply identically.
func inputReply(values ...uint16) []byte { return replyFor(fcReadInput, values...) }
func replyFor(fc byte, values ...uint16) []byte {
out := []byte{fc, 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 TestReadInputAsksTheInputTable(t *testing.T) {
addr, seen := serve(t, func(pdu []byte) []byte {
// A charger that keeps its measurements in input registers answers FC04
// and refuses FC03, so replying to the wrong code would hide a mix-up.
if pdu[0] != fcReadInput {
return []byte{pdu[0] | excMask, 0x02}
}
return inputReply(0x0102, 0x0304)
})
c := dial(t, addr)
got, err := c.ReadInput(context.Background(), 20041, 2)
if err != nil {
t.Fatalf("ReadInput: %v", err)
}
if len(got) != 2 || got[0] != 0x0102 || got[1] != 0x0304 {
t.Errorf("registers = %v, want [258 772]", got)
}
_ = c.Close()
frames := <-seen
if len(frames) != 1 {
t.Fatalf("server saw %d frames, want 1", len(frames))
}
if fc := frames[0][mbapLen]; fc != fcReadInput {
t.Errorf("function code = 0x%02x, want 0x%02x", fc, fcReadInput)
}
if a := binary.BigEndian.Uint16(frames[0][mbapLen+1:]); a != 20041 {
t.Errorf("address = %d, want 20041", a)
}
}
func TestReadInputRejectsHoldingReply(t *testing.T) {
addr, _ := serve(t, func(pdu []byte) []byte { return readReply(0x0102) })
c := dial(t, addr)
if _, err := c.ReadInput(context.Background(), 20041, 1); err == nil {
t.Fatal("expected an error when the reply echoes the other function code")
}
}
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")
}
}