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
8.0 KiB
Go
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")
|
|
}
|
|
}
|