]> git.giacomolli.uk - onehouse/blob - driver/m31/tcp_client.go
Exchange bounded M31 Modbus TCP requests
[onehouse] / driver / m31 / tcp_client.go
1 package m31
2
3 import (
4 "bytes"
5 "context"
6 "encoding/binary"
7 "errors"
8 "fmt"
9 "io"
10 "net"
11 "time"
12 )
13
14 const (
15 connectTimeout = 2 * time.Second
16 writeTimeout = time.Second
17 readTimeout = 2 * time.Second
18 )
19
20 type tcpClient struct {
21 conn net.Conn
22 unit byte
23 transaction uint16
24 }
25
26 // NewTCPClient supplies Modbus TCP functions 01, 02, 03, 05, 15, and 16 without
27 // opening a connection. Connect has a two-second timeout. Writes have a
28 // one-second timeout, and reads have a two-second timeout. Earlier caller
29 // deadlines apply to each operation. The caller owns Close.
30 func NewTCPClient() Client {
31 return &tcpClient{}
32 }
33
34 func (c *tcpClient) Connect(ctx context.Context, address string, unit byte) error {
35 if c.conn != nil {
36 return fmt.Errorf("modbus connection is already open")
37 }
38
39 if unit == 0 || unit > 247 {
40 return fmt.Errorf("modbus unit must be from one through 247")
41 }
42
43 ctx, cancel := context.WithTimeout(ctx, connectTimeout)
44 defer cancel()
45
46 conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
47 if err != nil {
48 return fmt.Errorf("connect Modbus transport: %w", err)
49 }
50
51 c.conn, c.unit = conn, unit
52
53 return nil
54 }
55
56 func (c *tcpClient) Close() error {
57 if c.conn == nil {
58 return nil
59 }
60
61 err := c.conn.Close()
62 c.conn = nil
63 if err != nil {
64 return fmt.Errorf("close Modbus transport: %w", err)
65 }
66
67 return nil
68 }
69
70 func (c *tcpClient) ReadInputs(ctx context.Context, count uint16) ([]bool, error) {
71 return c.readBits(ctx, 2, count)
72 }
73
74 func (c *tcpClient) ReadCoils(ctx context.Context, count uint16) ([]bool, error) {
75 return c.readBits(ctx, 1, count)
76 }
77
78 func (c *tcpClient) readBits(ctx context.Context, function byte, count uint16) ([]bool, error) {
79 if count == 0 || count > 2000 {
80 return nil, fmt.Errorf("bit read count must be from one through 2000")
81 }
82
83 data, err := c.exchange(ctx, function, words(0, count))
84 if err != nil {
85 return nil, fmt.Errorf("read Modbus bits: %w", err)
86 }
87
88 size := (int(count) + 7) / 8
89 if len(data) != size+1 || int(data[0]) != size {
90 return nil, fmt.Errorf("modbus bit response has an invalid length")
91 }
92
93 values := make([]bool, count)
94
95 for index := range values {
96 values[index] = data[1+index/8]&(1<<uint(index%8)) != 0
97 }
98
99 return values, nil
100 }
101
102 func (c *tcpClient) ReadRegisters(ctx context.Context, address uint16, count uint16) ([]uint16, error) {
103 if count == 0 || count > 125 || uint32(address)+uint32(count) > 65536 {
104 return nil, fmt.Errorf("invalid register read range")
105 }
106
107 data, err := c.exchange(ctx, 3, words(address, count))
108 if err != nil {
109 return nil, fmt.Errorf("read Modbus registers: %w", err)
110 }
111
112 if len(data) != int(count)*2+1 || int(data[0]) != int(count)*2 {
113 return nil, fmt.Errorf("modbus register response has an invalid length")
114 }
115
116 values := make([]uint16, count)
117
118 for index := range values {
119 values[index] = binary.BigEndian.Uint16(data[1+2*index:])
120 }
121
122 return values, nil
123 }
124
125 func (c *tcpClient) WriteCoil(ctx context.Context, address uint16, value bool) error {
126 var encoded uint16
127
128 if value {
129 encoded = 0xff00
130 }
131
132 return c.write(ctx, 5, words(address, encoded), words(address, encoded))
133 }
134
135 func (c *tcpClient) WriteCoils(ctx context.Context, address uint16, values []bool) error {
136 if len(values) == 0 || len(values) > 1968 || uint32(address)+uint32(len(values)) > 65536 {
137 return fmt.Errorf("invalid coil write range")
138 }
139
140 data := append(words(address, uint16(len(values))), byte((len(values)+7)/8))
141 data = append(data, make([]byte, (len(values)+7)/8)...)
142
143 for index, value := range values {
144 if value {
145 data[5+index/8] |= 1 << uint(index%8)
146 }
147 }
148
149 return c.write(ctx, 15, data, words(address, uint16(len(values))))
150 }
151
152 func (c *tcpClient) WriteRegister(ctx context.Context, address uint16, value uint16) error {
153 data := append(words(address, 1), 2)
154 data = append(data, words(value)...)
155
156 return c.write(ctx, 16, data, words(address, 1))
157 }
158
159 func (c *tcpClient) write(ctx context.Context, function byte, request []byte, expected []byte) error {
160 data, err := c.exchange(ctx, function, request)
161 if err != nil {
162 return fmt.Errorf("write Modbus value: %w", err)
163 }
164
165 if !bytes.Equal(data, expected) {
166 return fmt.Errorf("modbus write acknowledgement differs from request")
167 }
168
169 return nil
170 }
171
172 func (c *tcpClient) exchange(ctx context.Context, function byte, data []byte) (response []byte, err error) {
173 if ctx.Err() != nil {
174 return nil, ctx.Err()
175 }
176
177 if c.conn == nil {
178 return nil, fmt.Errorf("modbus transport is closed")
179 }
180
181 c.transaction++
182 request := append(words(c.transaction, 0, uint16(len(data)+2)), c.unit, function)
183 request = append(request, data...)
184 err = c.conn.SetWriteDeadline(operationDeadline(ctx, writeTimeout))
185 if err != nil {
186 return nil, fmt.Errorf("set Modbus write deadline: %w", err)
187 }
188
189 err = c.conn.SetReadDeadline(operationDeadline(ctx, readTimeout))
190 if err != nil {
191 return nil, fmt.Errorf("set Modbus read deadline: %w", err)
192 }
193
194 interrupted := make(chan struct{})
195
196 var interruptErr error
197
198 stop := context.AfterFunc(ctx, func() {
199 interruptErr = c.conn.SetDeadline(time.Now())
200
201 close(interrupted)
202 })
203 defer func() {
204 if !stop() {
205 <-interrupted
206
207 err = errors.Join(err, interruptErr, ctx.Err())
208 }
209 }()
210
211 count, err := c.conn.Write(request)
212 if err != nil {
213 return nil, fmt.Errorf("write Modbus request: %w", err)
214 }
215
216 if count != len(request) {
217 return nil, fmt.Errorf("write Modbus request: %w", io.ErrShortWrite)
218 }
219
220 var header [7]byte
221
222 _, err = io.ReadFull(c.conn, header[:])
223 if err != nil {
224 return nil, fmt.Errorf("read Modbus header: %w", err)
225 }
226
227 length := binary.BigEndian.Uint16(header[4:6])
228 if binary.BigEndian.Uint16(header[:2]) != c.transaction || binary.BigEndian.Uint16(header[2:4]) != 0 || header[6] != c.unit || length < 2 || length > 254 {
229 return nil, fmt.Errorf("invalid Modbus response header")
230 }
231
232 body := make([]byte, length-1)
233 _, err = io.ReadFull(c.conn, body)
234 if err != nil {
235 return nil, fmt.Errorf("read Modbus body: %w", err)
236 }
237
238 if body[0] == function|0x80 && len(body) == 2 {
239 return nil, fmt.Errorf("modbus exception %d", body[1])
240 }
241
242 if body[0] != function {
243 return nil, fmt.Errorf("invalid Modbus response function")
244 }
245
246 return body[1:], nil
247 }
248
249 func words(values ...uint16) []byte {
250 data := make([]byte, 2*len(values))
251
252 for index, value := range values {
253 binary.BigEndian.PutUint16(data[2*index:], value)
254 }
255
256 return data
257 }
258
259 func operationDeadline(ctx context.Context, timeout time.Duration) time.Time {
260 deadline := time.Now().Add(timeout)
261 caller, exists := ctx.Deadline()
262 if exists && caller.Before(deadline) {
263 return caller
264 }
265
266 return deadline
267 }