+package m31
+
+import (
+ "context"
+ "encoding/binary"
+ "errors"
+ "io"
+ "net"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestTCPClientEncodesAndChecksModbusOperations(t *testing.T) {
+ testCases := []struct {
+ name string
+ call func(*tcpClient) error
+ request []byte
+ reply []byte
+ }{
+ {
+ name: "reads low bit first across byte boundaries",
+ call: func(c *tcpClient) error {
+ value, err := c.ReadInputs(t.Context(), 9)
+ require.Equal(t, []bool{true, false, true, false, false, false, false, false, true}, value)
+
+ return err
+ },
+ request: []byte{2, 0, 0, 0, 9},
+ reply: []byte{2, 2, 5, 1},
+ },
+ {
+ name: "reads coil state",
+ call: func(c *tcpClient) error {
+ value, err := c.ReadCoils(t.Context(), 1)
+ require.Equal(t, []bool{true}, value)
+
+ return err
+ },
+ request: []byte{1, 0, 0, 0, 1},
+ reply: []byte{1, 1, 1},
+ },
+ {
+ name: "reads full sixteen bit registers",
+ call: func(c *tcpClient) error {
+ value, err := c.ReadRegisters(t.Context(), 8, 2)
+ require.Equal(t, []uint16{10000, 65535}, value)
+
+ return err
+ },
+ request: []byte{3, 0, 8, 0, 2},
+ reply: []byte{3, 4, 0x27, 0x10, 0xff, 0xff},
+ },
+ {
+ name: "writes true with function five",
+ call: func(c *tcpClient) error {
+ return c.WriteCoil(t.Context(), 7, true)
+ },
+ request: []byte{5, 0, 7, 0xff, 0},
+ reply: []byte{5, 0, 7, 0xff, 0},
+ },
+ {
+ name: "writes false with function five",
+ call: func(c *tcpClient) error {
+ return c.WriteCoil(t.Context(), 7, false)
+ },
+ request: []byte{5, 0, 7, 0, 0},
+ reply: []byte{5, 0, 7, 0, 0},
+ },
+ {
+ name: "writes a packed contiguous coil range",
+ call: func(c *tcpClient) error {
+ return c.WriteCoils(t.Context(), 6, []bool{true, false, true, false, false, false, false, false, true})
+ },
+ request: []byte{15, 0, 6, 0, 9, 2, 5, 1},
+ reply: []byte{15, 0, 6, 0, 9},
+ },
+ {
+ name: "writes a holding register with function sixteen",
+ call: func(c *tcpClient) error {
+ return c.WriteRegister(t.Context(), 0x30d4, 200)
+ },
+ request: []byte{16, 0x30, 0xd4, 0, 1, 2, 0, 200},
+ reply: []byte{16, 0x30, 0xd4, 0, 1},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ request, err := wireCall(t, tc.call, func(header []byte) []byte {
+ return append(append(header[:4:4], words(uint16(len(tc.reply)+1))...), append([]byte{7}, tc.reply...)...)
+ })
+ require.NoError(t, err)
+ require.Equal(t, append([]byte{0, 1, 0, 0, 0, byte(len(tc.request) + 1), 7}, tc.request...), request)
+ })
+ }
+}
+
+func TestTCPClientRejectsInvalidAndTruncatedResponses(t *testing.T) {
+ testCases := []struct {
+ name string
+ reply []byte
+ }{
+ {name: "rejects another transaction", reply: []byte{0, 2, 0, 0, 0, 4, 7, 1, 1, 0}},
+ {name: "rejects another protocol", reply: []byte{0, 1, 0, 1, 0, 4, 7, 1, 1, 0}},
+ {name: "rejects another unit", reply: []byte{0, 1, 0, 0, 0, 4, 8, 1, 1, 0}},
+ {name: "rejects empty PDU", reply: []byte{0, 1, 0, 0, 0, 1, 7}},
+ {name: "rejects excessive PDU", reply: []byte{0, 1, 0, 0, 0, 255, 7}},
+ {name: "rejects truncated header", reply: []byte{0, 1}},
+ {name: "rejects truncated body", reply: []byte{0, 1, 0, 0, 0, 4, 7, 1}},
+ {name: "rejects wrong function", reply: []byte{0, 1, 0, 0, 0, 4, 7, 2, 1, 0}},
+ {name: "rejects exception", reply: []byte{0, 1, 0, 0, 0, 3, 7, 0x81, 2}},
+ {name: "rejects wrong byte count", reply: []byte{0, 1, 0, 0, 0, 4, 7, 1, 2, 0}},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := wireCall(t, func(c *tcpClient) error {
+ _, err := c.ReadCoils(t.Context(), 1)
+ return err
+ }, func([]byte) []byte {
+ return tc.reply
+ })
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestTCPClientRejectsInvalidRangesAndAcknowledgements(t *testing.T) {
+ c := &tcpClient{}
+ _, err := c.ReadInputs(t.Context(), 0)
+ require.Error(t, err)
+
+ _, err = c.ReadCoils(t.Context(), 2001)
+ require.Error(t, err)
+
+ _, err = c.ReadRegisters(t.Context(), 0, 0)
+ require.Error(t, err)
+
+ _, err = c.ReadRegisters(t.Context(), 0, 126)
+ require.Error(t, err)
+
+ _, err = c.ReadRegisters(t.Context(), 65535, 2)
+ require.Error(t, err)
+ require.Error(t, c.WriteCoils(t.Context(), 0, nil))
+ require.Error(t, c.WriteCoils(t.Context(), 65535, []bool{true, true}))
+ require.Error(t, c.WriteCoils(t.Context(), 0, make([]bool, 1969)))
+ require.Error(t, c.WriteCoil(t.Context(), 0, true))
+ require.NoError(t, c.Close())
+ require.Error(t, c.Connect(t.Context(), "", 0))
+ require.Error(t, c.Connect(t.Context(), "", 248))
+ require.Error(t, c.Connect(t.Context(), "", 1))
+
+ _, err = wireCall(t, func(c *tcpClient) error {
+ return c.WriteRegister(t.Context(), 0, 20)
+ }, func(header []byte) []byte {
+ return append(header[:7:7], 16, 0, 0, 0, 2)
+ })
+ require.Error(t, err)
+
+ _, err = wireCall(t, func(c *tcpClient) error {
+ _, readErr := c.ReadRegisters(t.Context(), 0, 1)
+ return readErr
+ }, func(header []byte) []byte {
+ return append(header[:7:7], 3, 3, 0, 0, 0)
+ })
+ require.Error(t, err)
+}
+
+func TestTCPClientCancellationAndTimeouts(t *testing.T) {
+ testCases := []struct {
+ name string
+ cancel bool
+ write bool
+ }{
+ {name: "cancels a blocked write", cancel: true, write: true},
+ {name: "cancels a blocked read", cancel: true},
+ {name: "limits a blocked write", write: true},
+ {name: "limits a blocked read"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ conn, peer := net.Pipe()
+ c := &tcpClient{conn: conn, unit: 1}
+ t.Cleanup(func() {
+ require.NoError(t, c.Close())
+ require.NoError(t, peer.Close())
+ })
+
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ result := make(chan error, 1)
+
+ if !tc.write {
+ go func() {
+ var request [12]byte
+
+ _, err := io.ReadFull(peer, request[:])
+ result <- err
+ }()
+ }
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := c.ReadCoils(ctx, 1)
+ done <- err
+ }()
+
+ synctest.Wait()
+
+ if !tc.write {
+ require.NoError(t, <-result)
+ }
+
+ if tc.cancel {
+ cancel()
+ } else {
+ time.Sleep(2 * time.Second)
+ }
+
+ err := <-done
+ require.Error(t, err)
+
+ if tc.cancel {
+ require.ErrorIs(t, err, context.Canceled)
+ }
+ })
+ })
+ }
+}
+
+func wireCall(t *testing.T, call func(*tcpClient) error, response func([]byte) []byte) ([]byte, error) {
+ t.Helper()
+
+ conn, peer := net.Pipe()
+ c := &tcpClient{conn: conn, unit: 7}
+ t.Cleanup(func() {
+ require.NoError(t, c.Close())
+ require.NoError(t, peer.Close())
+ })
+
+ requests := make(chan []byte, 1)
+ done := make(chan error, 1)
+
+ go func() {
+ var header [7]byte
+
+ _, err := io.ReadFull(peer, header[:])
+ if err != nil {
+ done <- err
+ return
+ }
+
+ body := make([]byte, int(binary.BigEndian.Uint16(header[4:6]))-1)
+ _, err = io.ReadFull(peer, body)
+ requests <- append(header[:7:7], body...)
+ if err == nil {
+ _, err = peer.Write(response(header[:]))
+ }
+
+ if errors.Is(err, io.ErrClosedPipe) {
+ err = nil
+ }
+
+ err = errors.Join(err, peer.Close())
+ done <- err
+ }()
+
+ err := call(c)
+ require.NoError(t, c.Close())
+ require.NoError(t, <-done)
+
+ return <-requests, err
+}