]> git.giacomolli.uk - onehouse/blob - driver/m31/tcp_client_test.go
Exchange bounded M31 Modbus TCP requests
[onehouse] / driver / m31 / tcp_client_test.go
1 package m31
2
3 import (
4 "context"
5 "encoding/binary"
6 "errors"
7 "io"
8 "net"
9 "testing"
10 "testing/synctest"
11 "time"
12
13 "github.com/stretchr/testify/require"
14 )
15
16 func TestTCPClientEncodesAndChecksModbusOperations(t *testing.T) {
17 testCases := []struct {
18 name string
19 call func(*tcpClient) error
20 request []byte
21 reply []byte
22 }{
23 {
24 name: "reads low bit first across byte boundaries",
25 call: func(c *tcpClient) error {
26 value, err := c.ReadInputs(t.Context(), 9)
27 require.Equal(t, []bool{true, false, true, false, false, false, false, false, true}, value)
28
29 return err
30 },
31 request: []byte{2, 0, 0, 0, 9},
32 reply: []byte{2, 2, 5, 1},
33 },
34 {
35 name: "reads coil state",
36 call: func(c *tcpClient) error {
37 value, err := c.ReadCoils(t.Context(), 1)
38 require.Equal(t, []bool{true}, value)
39
40 return err
41 },
42 request: []byte{1, 0, 0, 0, 1},
43 reply: []byte{1, 1, 1},
44 },
45 {
46 name: "reads full sixteen bit registers",
47 call: func(c *tcpClient) error {
48 value, err := c.ReadRegisters(t.Context(), 8, 2)
49 require.Equal(t, []uint16{10000, 65535}, value)
50
51 return err
52 },
53 request: []byte{3, 0, 8, 0, 2},
54 reply: []byte{3, 4, 0x27, 0x10, 0xff, 0xff},
55 },
56 {
57 name: "writes true with function five",
58 call: func(c *tcpClient) error {
59 return c.WriteCoil(t.Context(), 7, true)
60 },
61 request: []byte{5, 0, 7, 0xff, 0},
62 reply: []byte{5, 0, 7, 0xff, 0},
63 },
64 {
65 name: "writes false with function five",
66 call: func(c *tcpClient) error {
67 return c.WriteCoil(t.Context(), 7, false)
68 },
69 request: []byte{5, 0, 7, 0, 0},
70 reply: []byte{5, 0, 7, 0, 0},
71 },
72 {
73 name: "writes a packed contiguous coil range",
74 call: func(c *tcpClient) error {
75 return c.WriteCoils(t.Context(), 6, []bool{true, false, true, false, false, false, false, false, true})
76 },
77 request: []byte{15, 0, 6, 0, 9, 2, 5, 1},
78 reply: []byte{15, 0, 6, 0, 9},
79 },
80 {
81 name: "writes a holding register with function sixteen",
82 call: func(c *tcpClient) error {
83 return c.WriteRegister(t.Context(), 0x30d4, 200)
84 },
85 request: []byte{16, 0x30, 0xd4, 0, 1, 2, 0, 200},
86 reply: []byte{16, 0x30, 0xd4, 0, 1},
87 },
88 }
89
90 for _, tc := range testCases {
91 t.Run(tc.name, func(t *testing.T) {
92 request, err := wireCall(t, tc.call, func(header []byte) []byte {
93 return append(append(header[:4:4], words(uint16(len(tc.reply)+1))...), append([]byte{7}, tc.reply...)...)
94 })
95 require.NoError(t, err)
96 require.Equal(t, append([]byte{0, 1, 0, 0, 0, byte(len(tc.request) + 1), 7}, tc.request...), request)
97 })
98 }
99 }
100
101 func TestTCPClientRejectsInvalidAndTruncatedResponses(t *testing.T) {
102 testCases := []struct {
103 name string
104 reply []byte
105 }{
106 {name: "rejects another transaction", reply: []byte{0, 2, 0, 0, 0, 4, 7, 1, 1, 0}},
107 {name: "rejects another protocol", reply: []byte{0, 1, 0, 1, 0, 4, 7, 1, 1, 0}},
108 {name: "rejects another unit", reply: []byte{0, 1, 0, 0, 0, 4, 8, 1, 1, 0}},
109 {name: "rejects empty PDU", reply: []byte{0, 1, 0, 0, 0, 1, 7}},
110 {name: "rejects excessive PDU", reply: []byte{0, 1, 0, 0, 0, 255, 7}},
111 {name: "rejects truncated header", reply: []byte{0, 1}},
112 {name: "rejects truncated body", reply: []byte{0, 1, 0, 0, 0, 4, 7, 1}},
113 {name: "rejects wrong function", reply: []byte{0, 1, 0, 0, 0, 4, 7, 2, 1, 0}},
114 {name: "rejects exception", reply: []byte{0, 1, 0, 0, 0, 3, 7, 0x81, 2}},
115 {name: "rejects wrong byte count", reply: []byte{0, 1, 0, 0, 0, 4, 7, 1, 2, 0}},
116 }
117
118 for _, tc := range testCases {
119 t.Run(tc.name, func(t *testing.T) {
120 _, err := wireCall(t, func(c *tcpClient) error {
121 _, err := c.ReadCoils(t.Context(), 1)
122 return err
123 }, func([]byte) []byte {
124 return tc.reply
125 })
126 require.Error(t, err)
127 })
128 }
129 }
130
131 func TestTCPClientRejectsInvalidRangesAndAcknowledgements(t *testing.T) {
132 c := &tcpClient{}
133 _, err := c.ReadInputs(t.Context(), 0)
134 require.Error(t, err)
135
136 _, err = c.ReadCoils(t.Context(), 2001)
137 require.Error(t, err)
138
139 _, err = c.ReadRegisters(t.Context(), 0, 0)
140 require.Error(t, err)
141
142 _, err = c.ReadRegisters(t.Context(), 0, 126)
143 require.Error(t, err)
144
145 _, err = c.ReadRegisters(t.Context(), 65535, 2)
146 require.Error(t, err)
147 require.Error(t, c.WriteCoils(t.Context(), 0, nil))
148 require.Error(t, c.WriteCoils(t.Context(), 65535, []bool{true, true}))
149 require.Error(t, c.WriteCoils(t.Context(), 0, make([]bool, 1969)))
150 require.Error(t, c.WriteCoil(t.Context(), 0, true))
151 require.NoError(t, c.Close())
152 require.Error(t, c.Connect(t.Context(), "", 0))
153 require.Error(t, c.Connect(t.Context(), "", 248))
154 require.Error(t, c.Connect(t.Context(), "", 1))
155
156 _, err = wireCall(t, func(c *tcpClient) error {
157 return c.WriteRegister(t.Context(), 0, 20)
158 }, func(header []byte) []byte {
159 return append(header[:7:7], 16, 0, 0, 0, 2)
160 })
161 require.Error(t, err)
162
163 _, err = wireCall(t, func(c *tcpClient) error {
164 _, readErr := c.ReadRegisters(t.Context(), 0, 1)
165 return readErr
166 }, func(header []byte) []byte {
167 return append(header[:7:7], 3, 3, 0, 0, 0)
168 })
169 require.Error(t, err)
170 }
171
172 func TestTCPClientCancellationAndTimeouts(t *testing.T) {
173 testCases := []struct {
174 name string
175 cancel bool
176 write bool
177 }{
178 {name: "cancels a blocked write", cancel: true, write: true},
179 {name: "cancels a blocked read", cancel: true},
180 {name: "limits a blocked write", write: true},
181 {name: "limits a blocked read"},
182 }
183
184 for _, tc := range testCases {
185 t.Run(tc.name, func(t *testing.T) {
186 synctest.Test(t, func(t *testing.T) {
187 conn, peer := net.Pipe()
188 c := &tcpClient{conn: conn, unit: 1}
189 t.Cleanup(func() {
190 require.NoError(t, c.Close())
191 require.NoError(t, peer.Close())
192 })
193
194 ctx, cancel := context.WithCancel(t.Context())
195 defer cancel()
196
197 result := make(chan error, 1)
198
199 if !tc.write {
200 go func() {
201 var request [12]byte
202
203 _, err := io.ReadFull(peer, request[:])
204 result <- err
205 }()
206 }
207
208 done := make(chan error, 1)
209 go func() {
210 _, err := c.ReadCoils(ctx, 1)
211 done <- err
212 }()
213
214 synctest.Wait()
215
216 if !tc.write {
217 require.NoError(t, <-result)
218 }
219
220 if tc.cancel {
221 cancel()
222 } else {
223 time.Sleep(2 * time.Second)
224 }
225
226 err := <-done
227 require.Error(t, err)
228
229 if tc.cancel {
230 require.ErrorIs(t, err, context.Canceled)
231 }
232 })
233 })
234 }
235 }
236
237 func wireCall(t *testing.T, call func(*tcpClient) error, response func([]byte) []byte) ([]byte, error) {
238 t.Helper()
239
240 conn, peer := net.Pipe()
241 c := &tcpClient{conn: conn, unit: 7}
242 t.Cleanup(func() {
243 require.NoError(t, c.Close())
244 require.NoError(t, peer.Close())
245 })
246
247 requests := make(chan []byte, 1)
248 done := make(chan error, 1)
249
250 go func() {
251 var header [7]byte
252
253 _, err := io.ReadFull(peer, header[:])
254 if err != nil {
255 done <- err
256 return
257 }
258
259 body := make([]byte, int(binary.BigEndian.Uint16(header[4:6]))-1)
260 _, err = io.ReadFull(peer, body)
261 requests <- append(header[:7:7], body...)
262 if err == nil {
263 _, err = peer.Write(response(header[:]))
264 }
265
266 if errors.Is(err, io.ErrClosedPipe) {
267 err = nil
268 }
269
270 err = errors.Join(err, peer.Close())
271 done <- err
272 }()
273
274 err := call(c)
275 require.NoError(t, c.Close())
276 require.NoError(t, <-done)
277
278 return <-requests, err
279 }