rmodbus-ccutrer 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/NEWS.md +180 -0
- data/README.md +115 -0
- data/Rakefile +29 -0
- data/examples/perfomance_rtu.rb +56 -0
- data/examples/perfomance_rtu_via_tcp.rb +55 -0
- data/examples/perfomance_tcp.rb +55 -0
- data/examples/simple-xpca-gateway.rb +84 -0
- data/examples/use_rtu_via_tcp_modbus.rb +22 -0
- data/examples/use_tcp_modbus.rb +23 -0
- data/lib/rmodbus.rb +21 -0
- data/lib/rmodbus/client.rb +94 -0
- data/lib/rmodbus/client/slave.rb +345 -0
- data/lib/rmodbus/debug.rb +25 -0
- data/lib/rmodbus/errors.rb +42 -0
- data/lib/rmodbus/ext.rb +85 -0
- data/lib/rmodbus/options.rb +6 -0
- data/lib/rmodbus/proxy.rb +41 -0
- data/lib/rmodbus/rtu.rb +122 -0
- data/lib/rmodbus/rtu_client.rb +43 -0
- data/lib/rmodbus/rtu_server.rb +48 -0
- data/lib/rmodbus/rtu_slave.rb +48 -0
- data/lib/rmodbus/rtu_via_tcp_server.rb +35 -0
- data/lib/rmodbus/server.rb +246 -0
- data/lib/rmodbus/server/slave.rb +16 -0
- data/lib/rmodbus/sp.rb +36 -0
- data/lib/rmodbus/tcp.rb +31 -0
- data/lib/rmodbus/tcp_client.rb +25 -0
- data/lib/rmodbus/tcp_server.rb +67 -0
- data/lib/rmodbus/tcp_slave.rb +55 -0
- data/lib/rmodbus/version.rb +3 -0
- data/spec/client_spec.rb +88 -0
- data/spec/exception_spec.rb +120 -0
- data/spec/ext_spec.rb +52 -0
- data/spec/logging_spec.rb +89 -0
- data/spec/proxy_spec.rb +74 -0
- data/spec/read_rtu_response_spec.rb +92 -0
- data/spec/response_mismach_spec.rb +163 -0
- data/spec/rtu_client_spec.rb +86 -0
- data/spec/rtu_server_spec.rb +31 -0
- data/spec/rtu_via_tcp_client_spec.rb +76 -0
- data/spec/rtu_via_tcp_server_spec.rb +89 -0
- data/spec/slave_spec.rb +55 -0
- data/spec/spec_helper.rb +54 -0
- data/spec/tcp_client_spec.rb +88 -0
- data/spec/tcp_server_spec.rb +158 -0
- metadata +206 -0
@@ -0,0 +1,35 @@
|
|
1
|
+
begin
|
2
|
+
require 'gserver'
|
3
|
+
rescue
|
4
|
+
warn "[WARNING] Install `gserver` gem for use RTUViaTCPServer"
|
5
|
+
end
|
6
|
+
|
7
|
+
module ModBus
|
8
|
+
# RTU over TCP server implementation
|
9
|
+
# @example
|
10
|
+
# srv = RTUViaTCPServer.new(10002)
|
11
|
+
# slave = src.with_slave(1)
|
12
|
+
# slave.coils = [1,0,1,1]
|
13
|
+
# slave.discrete_inputs = [1,1,0,0]
|
14
|
+
# slave.holding_registers = [1,2,3,4]
|
15
|
+
# slave.input_registers = [1,2,3,4]
|
16
|
+
# srv.debug = true
|
17
|
+
# srv.start
|
18
|
+
class RTUViaTCPServer < GServer
|
19
|
+
include Debug
|
20
|
+
include RTU
|
21
|
+
include Server
|
22
|
+
|
23
|
+
# Init server
|
24
|
+
# @param [Integer] port listen port
|
25
|
+
# @param [Integer] uid slave device
|
26
|
+
# @param [Hash] opts options of server
|
27
|
+
# @option opts [String] :host host of server default '127.0.0.1'
|
28
|
+
# @option opts [Float, Integer] :max_connection max of TCP connection with server default 4
|
29
|
+
def initialize(port = 10002, opts = {})
|
30
|
+
opts[:host] = DEFAULT_HOST unless opts[:host]
|
31
|
+
opts[:max_connection] = 4 unless opts[:max_connection]
|
32
|
+
super(port, host = opts[:host], maxConnection = opts[:max_connection])
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,246 @@
|
|
1
|
+
module ModBus
|
2
|
+
# Module for implementation ModBus server
|
3
|
+
module Server
|
4
|
+
autoload :Slave, 'rmodbus/server/slave'
|
5
|
+
|
6
|
+
attr_accessor :promiscuous, :request_callback, :response_callback
|
7
|
+
|
8
|
+
Funcs = [1,2,3,4,5,6,15,16,22,23]
|
9
|
+
|
10
|
+
def with_slave(uid)
|
11
|
+
slave = slaves[uid] ||= Server::Slave.new
|
12
|
+
if block_given?
|
13
|
+
yield slave
|
14
|
+
else
|
15
|
+
slave
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def slaves
|
22
|
+
@slaves ||= {}
|
23
|
+
end
|
24
|
+
|
25
|
+
def exec_req(uid, func, params, pdu, is_response: false)
|
26
|
+
if is_response
|
27
|
+
log("Server RX response #{func & 0x7f} from #{uid}: #{params.inspect}")
|
28
|
+
else
|
29
|
+
log("Server RX function #{func} to #{uid}: #{params.inspect}")
|
30
|
+
end
|
31
|
+
request_callback&.call(uid, func, params) unless is_response
|
32
|
+
|
33
|
+
if uid == 0
|
34
|
+
slaves.each_key { |specific_uid| exec_req(specific_uid, func, params, pdu) }
|
35
|
+
return
|
36
|
+
end
|
37
|
+
slave = slaves[uid]
|
38
|
+
return nil if !slave && !promiscuous
|
39
|
+
|
40
|
+
if promiscuous && !slave && is_response
|
41
|
+
# we saw a request to a slave that we don't own; try
|
42
|
+
# and parse this as a response, not a request
|
43
|
+
|
44
|
+
response_callback&.call(uid, func, params, @pending_response_req)
|
45
|
+
@pending_response_req = nil
|
46
|
+
return
|
47
|
+
end
|
48
|
+
|
49
|
+
unless Funcs.include?(func)
|
50
|
+
log("Server RX unrecognized function #{func} to #{uid}")
|
51
|
+
return unless slave
|
52
|
+
return (func | 0x80).chr + 1.chr
|
53
|
+
end
|
54
|
+
|
55
|
+
# keep track of the request so that promiscuous printing of the response can have context if necessary
|
56
|
+
@pending_response_req = params
|
57
|
+
|
58
|
+
return unless slave
|
59
|
+
pdu = process_func(func, slave, pdu, params)
|
60
|
+
if response_callback
|
61
|
+
res = parse_response(pdu.getbyte(0), pdu)
|
62
|
+
response_callback.call(uid, pdu.getbyte(0), res, params)
|
63
|
+
end
|
64
|
+
pdu
|
65
|
+
end
|
66
|
+
|
67
|
+
def parse_request(func, req)
|
68
|
+
case func
|
69
|
+
when 1, 2, 3, 4
|
70
|
+
parse_read_func(req)
|
71
|
+
when 5
|
72
|
+
parse_write_coil_func(req)
|
73
|
+
when 6
|
74
|
+
parse_write_register_func(req)
|
75
|
+
when 15
|
76
|
+
parse_write_multiple_coils_func(req)
|
77
|
+
when 16
|
78
|
+
parse_write_multiple_registers_func(req)
|
79
|
+
when 22
|
80
|
+
parse_mask_write_register_func(req)
|
81
|
+
when 23
|
82
|
+
parse_read_write_multiple_registers_func(req)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def parse_response(func, res)
|
87
|
+
if func & 0x80 == 0x80 && Funcs.include?(func & 0x7f)
|
88
|
+
return nil unless res.length == 2
|
89
|
+
return { err: res[1].ord }
|
90
|
+
end
|
91
|
+
|
92
|
+
case func
|
93
|
+
when 1, 2
|
94
|
+
return nil unless res.length == res[1].ord + 2
|
95
|
+
res[2..-1].unpack_bits
|
96
|
+
when 3, 4, 23
|
97
|
+
return nil unless res.length == res[1].ord + 2
|
98
|
+
res[2..-1].unpack('n*')
|
99
|
+
when 5, 6, 15, 16
|
100
|
+
return nil unless res.length == 5
|
101
|
+
{}
|
102
|
+
when 22
|
103
|
+
return nil unless res.length == 7
|
104
|
+
{}
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def process_func(func, slave, req, params)
|
109
|
+
case func
|
110
|
+
when 1
|
111
|
+
unless (err = validate_read_func(params, slave.coils, 2000))
|
112
|
+
val = slave.coils[params[:addr],params[:quant]].pack_to_word
|
113
|
+
pdu = func.chr + val.size.chr + val
|
114
|
+
end
|
115
|
+
when 2
|
116
|
+
unless (err = validate_read_func(params, slave.discrete_inputs, 2000))
|
117
|
+
val = slave.discrete_inputs[params[:addr],params[:quant]].pack_to_word
|
118
|
+
pdu = func.chr + val.size.chr + val
|
119
|
+
end
|
120
|
+
when 3
|
121
|
+
unless (err = validate_read_func(params, slave.holding_registers))
|
122
|
+
pdu = func.chr + (params[:quant] * 2).chr + slave.holding_registers[params[:addr],params[:quant]].pack('n*')
|
123
|
+
end
|
124
|
+
when 4
|
125
|
+
unless (err = validate_read_func(params, slave.input_registers))
|
126
|
+
pdu = func.chr + (params[:quant] * 2).chr + slave.input_registers[params[:addr],params[:quant]].pack('n*')
|
127
|
+
end
|
128
|
+
when 5
|
129
|
+
unless (err = validate_write_coil_func(params, slave))
|
130
|
+
params[:val] = 1 if params[:val] == 0xff00
|
131
|
+
slave.coils[params[:addr]] = params[:val]
|
132
|
+
pdu = req
|
133
|
+
end
|
134
|
+
when 6
|
135
|
+
unless (err = validate_write_register_func(params, slave))
|
136
|
+
slave.holding_registers[params[:addr]] = params[:val]
|
137
|
+
pdu = req
|
138
|
+
end
|
139
|
+
when 15
|
140
|
+
unless (err = validate_write_multiple_coils_func(params, slave))
|
141
|
+
slave.coils[params[:addr],params[:quant]] = params[:val][0,params[:quant]]
|
142
|
+
pdu = req[0,5]
|
143
|
+
end
|
144
|
+
when 16
|
145
|
+
unless (err = validate_write_multiple_registers_func(params, slave))
|
146
|
+
slave.holding_registers[params[:addr],params[:quant]] = params[:val]
|
147
|
+
pdu = req[0,5]
|
148
|
+
end
|
149
|
+
when 22
|
150
|
+
unless (err = validate_write_register_func(params, slave))
|
151
|
+
addr = params[:addr]
|
152
|
+
and_mask = params[:and_mask]
|
153
|
+
slave.holding_registers[addr] = (slave.holding_registers[addr] & and_mask) | (params[:or_mask] & ~and_mask)
|
154
|
+
pdu = req
|
155
|
+
end
|
156
|
+
when 23
|
157
|
+
unless (err = validate_read_write_multiple_registers_func(params, slave))
|
158
|
+
slave.holding_registers[params[:write][:addr],params[:write][:quant]] = params[:write][:val]
|
159
|
+
pdu = func.chr + (params[:read][:quant] * 2).chr + slave.holding_registers[params[:read][:addr],params[:read][:quant]].pack('n*')
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
if err
|
164
|
+
(func | 0x80).chr + err.chr
|
165
|
+
else
|
166
|
+
pdu
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def parse_read_func(req, expected_length = 5)
|
171
|
+
return nil if expected_length && req.length != expected_length
|
172
|
+
{ quant: req[3,2].unpack('n')[0], addr: req[1,2].unpack('n')[0] }
|
173
|
+
end
|
174
|
+
|
175
|
+
def validate_read_func(params, field, quant_max=0x7d)
|
176
|
+
return 3 unless params[:quant] <= quant_max
|
177
|
+
return 2 unless params[:addr] + params[:quant] <= field.size
|
178
|
+
end
|
179
|
+
|
180
|
+
def parse_write_coil_func(req)
|
181
|
+
return nil unless req.length == 5
|
182
|
+
{ addr: req[1,2].unpack('n')[0], val: req[3,2].unpack('n')[0] }
|
183
|
+
end
|
184
|
+
|
185
|
+
def validate_write_coil_func(params, slave)
|
186
|
+
return 2 unless params[:addr] <= slave.coils.size
|
187
|
+
return 3 unless params[:val] == 0 or params[:val] == 0xff00
|
188
|
+
end
|
189
|
+
|
190
|
+
def parse_write_register_func(req)
|
191
|
+
return nil unless req.length == 5
|
192
|
+
{ addr: req[1,2].unpack('n')[0], val: req[3,2].unpack('n')[0] }
|
193
|
+
end
|
194
|
+
|
195
|
+
def validate_write_register_func(params, slave)
|
196
|
+
return 2 unless params[:addr] <= slave.holding_registers.size
|
197
|
+
end
|
198
|
+
|
199
|
+
def parse_write_multiple_coils_func(req)
|
200
|
+
return nil if req.length < 7
|
201
|
+
params = parse_read_func(req, nil)
|
202
|
+
return nil if req.length != 6 + (params[:quant] + 7) / 8
|
203
|
+
params[:val] = req[6,params[:quant]].unpack_bits
|
204
|
+
params
|
205
|
+
end
|
206
|
+
|
207
|
+
def validate_write_multiple_coils_func(params, slave)
|
208
|
+
validate_read_func(params, slave.coils)
|
209
|
+
end
|
210
|
+
|
211
|
+
def parse_write_multiple_registers_func(req)
|
212
|
+
return nil if req.length < 8
|
213
|
+
params = parse_read_func(req, nil)
|
214
|
+
return nil if req.length != 6 + params[:quant] * 2
|
215
|
+
params[:val] = req[6,params[:quant] * 2].unpack('n*')
|
216
|
+
params
|
217
|
+
end
|
218
|
+
|
219
|
+
def validate_write_multiple_registers_func(params, slave)
|
220
|
+
validate_read_func(params, slave.holding_registers)
|
221
|
+
end
|
222
|
+
|
223
|
+
def parse_mask_write_register_func(req)
|
224
|
+
return nil if req.length != 7
|
225
|
+
{
|
226
|
+
addr: req[1,2].unpack('n')[0],
|
227
|
+
and_mask: req[3,2].unpack('n')[0],
|
228
|
+
or_mask: req[5,2].unpack('n')[0]
|
229
|
+
}
|
230
|
+
end
|
231
|
+
|
232
|
+
def parse_read_write_multiple_registers_func(req)
|
233
|
+
return nil if req.length < 12
|
234
|
+
params = { read: parse_read_func(req, nil),
|
235
|
+
write: parse_write_multiple_registers_func(req[4..-1])}
|
236
|
+
return nil if params[:write].nil?
|
237
|
+
params
|
238
|
+
end
|
239
|
+
|
240
|
+
def validate_read_write_multiple_registers_func(params, slave)
|
241
|
+
result = validate_read_func(params[:read], slave.holding_registers)
|
242
|
+
return result if result
|
243
|
+
validate_write_multiple_registers_func(params[:write], slave)
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'timeout'
|
2
|
+
|
3
|
+
module ModBus
|
4
|
+
module Server
|
5
|
+
class Slave
|
6
|
+
attr_accessor :coils, :discrete_inputs, :holding_registers, :input_registers
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@coils = []
|
10
|
+
@discrete_inputs = []
|
11
|
+
@holding_registers =[]
|
12
|
+
@input_registers = []
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/lib/rmodbus/sp.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
begin
|
2
|
+
require 'serialport'
|
3
|
+
rescue Exception => e
|
4
|
+
warn "[WARNING] Install `serialport` gem for use RTU protocols"
|
5
|
+
end
|
6
|
+
|
7
|
+
module ModBus
|
8
|
+
module SP
|
9
|
+
attr_reader :port, :baud, :data_bits, :stop_bits, :parity, :read_timeout
|
10
|
+
# Open serial port
|
11
|
+
# @param [String] port name serial ports ("/dev/ttyS0" POSIX, "com1" - Windows)
|
12
|
+
# @param [Integer] baud rate serial port (default 9600)
|
13
|
+
# @param [Hash] opts the options of serial port
|
14
|
+
#
|
15
|
+
# @option opts [Integer] :data_bits from 5 to 8
|
16
|
+
# @option opts [Integer] :stop_bits 1 or 2
|
17
|
+
# @option opts [Integer] :parity NONE, EVEN or ODD
|
18
|
+
# @option opts [Integer] :read_timeout default 100 ms
|
19
|
+
# @return [SerialPort] io serial port
|
20
|
+
def open_serial_port(port, baud, opts = {})
|
21
|
+
@port, @baud = port, baud
|
22
|
+
|
23
|
+
@data_bits, @stop_bits, @parity, @read_timeout = 8, 1, SerialPort::NONE, 100
|
24
|
+
|
25
|
+
@data_bits = opts[:data_bits] unless opts[:data_bits].nil?
|
26
|
+
@stop_bits = opts[:stop_bits] unless opts[:stop_bits].nil?
|
27
|
+
@parity = opts[:parity] unless opts[:parity].nil?
|
28
|
+
@read_timeout = opts[:read_timeout] unless opts[:read_timeout].nil?
|
29
|
+
|
30
|
+
io = SerialPort.new(@port, @baud, @data_bits, @stop_bits, @parity)
|
31
|
+
io.flow_control = SerialPort::NONE
|
32
|
+
io.read_timeout = @read_timeout
|
33
|
+
io
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
data/lib/rmodbus/tcp.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'socket'
|
2
|
+
|
3
|
+
module ModBus
|
4
|
+
module TCP
|
5
|
+
include Errors
|
6
|
+
attr_reader :ipaddr, :port
|
7
|
+
# Open TCP socket
|
8
|
+
#
|
9
|
+
# @param [String] ipaddr IP address of remote server
|
10
|
+
# @param [Integer] port connection port
|
11
|
+
# @param [Hash] opts options of connection
|
12
|
+
# @option opts [Float, Integer] :connect_timeout seconds timeout for open socket
|
13
|
+
# @return [Socket] socket
|
14
|
+
#
|
15
|
+
# @raise [ModBusTimeout] timed out attempting to create connection
|
16
|
+
def open_tcp_connection(ipaddr, port, opts = {})
|
17
|
+
@ipaddr, @port = ipaddr, port
|
18
|
+
|
19
|
+
timeout = opts[:connect_timeout] ||= 1
|
20
|
+
|
21
|
+
io = nil
|
22
|
+
begin
|
23
|
+
io = Socket.tcp(@ipaddr, @port, nil, nil, connect_timeout: timeout)
|
24
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT
|
25
|
+
raise ModBusTimeout.new, 'Timed out attempting to create connection'
|
26
|
+
end
|
27
|
+
|
28
|
+
io
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module ModBus
|
2
|
+
# TCP client implementation
|
3
|
+
# @example
|
4
|
+
# TCPClient.connect('127.0.0.1', 502) do |cl|
|
5
|
+
# cl.with_slave(uid) do |slave|
|
6
|
+
# slave.holding_registers[0..100]
|
7
|
+
# end
|
8
|
+
# end
|
9
|
+
#
|
10
|
+
# @see TCP#open_tcp_connection
|
11
|
+
# @see Client#initialize
|
12
|
+
class TCPClient < Client
|
13
|
+
include TCP
|
14
|
+
|
15
|
+
protected
|
16
|
+
# Open TCP\IP connection
|
17
|
+
def open_connection(ipaddr, port = 502, opts = {})
|
18
|
+
open_tcp_connection(ipaddr, port, opts)
|
19
|
+
end
|
20
|
+
|
21
|
+
def get_slave(uid, io)
|
22
|
+
TCPSlave.new(uid, io)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
begin
|
2
|
+
require 'gserver'
|
3
|
+
rescue
|
4
|
+
warn "[WARNING] Install `gserver` gem for use TCPServer"
|
5
|
+
end
|
6
|
+
|
7
|
+
module ModBus
|
8
|
+
# TCP server implementation
|
9
|
+
# @example
|
10
|
+
# srv = TCPServer.new(10002)
|
11
|
+
# slave = srv.with_slave(255)
|
12
|
+
# slave.coils = [1,0,1,1]
|
13
|
+
# slave.discrete_inputs = [1,1,0,0]
|
14
|
+
# slave.holding_registers = [1,2,3,4]
|
15
|
+
# slave.input_registers = [1,2,3,4]
|
16
|
+
# srv.debug = true
|
17
|
+
# srv.start
|
18
|
+
class TCPServer < GServer
|
19
|
+
include Debug
|
20
|
+
include Server
|
21
|
+
|
22
|
+
# Init server
|
23
|
+
# @param [Integer] port listen port
|
24
|
+
# @param [Integer] uid slave device
|
25
|
+
# @param [Hash] opts options of server
|
26
|
+
# @option opts [String] :host host of server default '127.0.0.1'
|
27
|
+
# @option opts [Float, Integer] :max_connection max of TCP connection with server default 4
|
28
|
+
def initialize(port = 502, opts = {})
|
29
|
+
opts[:host] = DEFAULT_HOST unless opts[:host]
|
30
|
+
opts[:max_connection] = 4 unless opts[:max_connection]
|
31
|
+
super(port, host = opts[:host], maxConnection = opts[:max_connection])
|
32
|
+
end
|
33
|
+
|
34
|
+
# set the default param
|
35
|
+
def with_slave(uid = 255)
|
36
|
+
super
|
37
|
+
end
|
38
|
+
|
39
|
+
# Serve requests
|
40
|
+
# @param [TCPSocket] io socket
|
41
|
+
def serve(io)
|
42
|
+
while not stopped?
|
43
|
+
header = io.read(7)
|
44
|
+
tx_id = header[0,2]
|
45
|
+
proto_id = header[2,2]
|
46
|
+
len = header[4,2].unpack('n')[0]
|
47
|
+
unit_id = header.getbyte(6)
|
48
|
+
if proto_id == "\x00\x00"
|
49
|
+
req = io.read(len - 1)
|
50
|
+
log "Server RX (#{req.size} bytes): #{logging_bytes(req)}"
|
51
|
+
|
52
|
+
func = req.getbyte(0)
|
53
|
+
params = parse_request(func, req)
|
54
|
+
pdu = exec_req(unit_id, func, params, req)
|
55
|
+
|
56
|
+
if pdu
|
57
|
+
resp = tx_id + "\0\0" + (pdu.size + 1).to_word + unit_id.chr + pdu
|
58
|
+
log "Server TX (#{resp.size} bytes): #{logging_bytes(resp)}"
|
59
|
+
io.write resp
|
60
|
+
else
|
61
|
+
log "Ignored server RX (invalid unit ID #{unit_id}, #{req.size} bytes): #{logging_bytes(req)}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|