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>
331 lines
9.5 KiB
Go
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")
|
|
}
|
|
}
|