rmodbus 0.3.1 → 0.4.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.
- data/ChangeLog +36 -2
- data/README +14 -15
- data/Rakefile +45 -0
- data/examples/perfomance_rtu.rb +58 -0
- data/examples/perfomance_tcp.rb +57 -0
- data/examples/use_tcp_modbus.rb +1 -0
- data/lib/rmodbus/client.rb +20 -7
- data/lib/rmodbus/crc16.rb +69 -0
- data/lib/rmodbus/ext.rb +65 -61
- data/lib/rmodbus/parsers.rb +133 -0
- data/lib/rmodbus/rtu_client.rb +107 -62
- data/lib/rmodbus/rtu_server.rb +90 -0
- data/lib/rmodbus/tcp_client.rb +47 -3
- data/lib/rmodbus/tcp_server.rb +18 -118
- data/lib/rmodbus.rb +1 -0
- data/spec/exception_spec.rb +117 -0
- data/spec/ext_spec.rb +5 -1
- data/spec/logging_spec.rb +69 -0
- data/spec/rtu_client_spec.rb +40 -10
- data/spec/rtu_server_spec.rb +31 -0
- data/spec/tcp_client_spec.rb +25 -0
- data/spec/tcp_server_spec.rb +2 -2
- metadata +15 -6
data/lib/rmodbus/tcp_server.rb
CHANGED
@@ -11,21 +11,30 @@
|
|
11
11
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
12
12
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
13
13
|
# GNU General Public License for more details.
|
14
|
-
require 'rmodbus/
|
14
|
+
require 'rmodbus/parsers'
|
15
15
|
require 'gserver'
|
16
16
|
|
17
17
|
|
18
18
|
module ModBus
|
19
|
-
|
20
19
|
class TCPServer < GServer
|
20
|
+
include Parsers
|
21
21
|
|
22
|
-
attr_accessor :coils, :
|
22
|
+
attr_accessor :coils, :discrete_inputs, :holding_registers, :input_registers
|
23
23
|
|
24
|
-
|
25
|
-
|
24
|
+
def discret_inputs
|
25
|
+
warn "[DEPRECATION] `discret_inputs` is deprecated. Please use `discrete_inputs` instead."
|
26
|
+
@discrete_inputs
|
27
|
+
end
|
28
|
+
|
29
|
+
def discret_inputs=(val)
|
30
|
+
warn "[DEPRECATION] `discret_inputs=` is deprecated. Please use `discrete_inputs=` instead."
|
31
|
+
@discrete_inputs=val
|
32
|
+
end
|
33
|
+
|
34
|
+
|
26
35
|
def initialize(port = 502, uid = 1)
|
27
36
|
@coils = []
|
28
|
-
@
|
37
|
+
@discrete_inputs = []
|
29
38
|
@holding_registers =[]
|
30
39
|
@input_registers = []
|
31
40
|
@uid = uid
|
@@ -44,119 +53,10 @@ module ModBus
|
|
44
53
|
len = req[4,2].unpack('n')[0]
|
45
54
|
req = io.read(len - 1)
|
46
55
|
|
47
|
-
|
56
|
+
pdu = exec_req(req, @coils, @discrete_inputs, @holding_registers, @input_registers)
|
48
57
|
|
49
|
-
|
50
|
-
param = { :err => 1 }
|
51
|
-
end
|
52
|
-
|
53
|
-
case func
|
54
|
-
when 1
|
55
|
-
param = parse_read_func(req, @coils)
|
56
|
-
if param[:err] == 0
|
57
|
-
val = @coils[param[:addr],param[:quant]].pack_to_word
|
58
|
-
res = func.chr + val.size.chr + val
|
59
|
-
end
|
60
|
-
when 2
|
61
|
-
param = parse_read_func(req, @discret_inputs)
|
62
|
-
if param[:err] == 0
|
63
|
-
val = @discret_inputs[param[:addr],param[:quant]].pack_to_word
|
64
|
-
res = func.chr + val.size.chr + val
|
65
|
-
end
|
66
|
-
when 3
|
67
|
-
param = parse_read_func(req, @holding_registers)
|
68
|
-
if param[:err] == 0
|
69
|
-
res = func.chr + (param[:quant] * 2).chr + @holding_registers[param[:addr],param[:quant]].pack('n*')
|
70
|
-
end
|
71
|
-
when 4
|
72
|
-
param = parse_read_func(req, @input_registers)
|
73
|
-
if param[:err] == 0
|
74
|
-
res = func.chr + (param[:quant] * 2).chr + @input_registers[param[:addr],param[:quant]].pack('n*')
|
75
|
-
end
|
76
|
-
when 5
|
77
|
-
param = parse_write_coil_func(req)
|
78
|
-
if param[:err] == 0
|
79
|
-
@coils[param[:addr]] = param[:val]
|
80
|
-
res = func.chr + req
|
81
|
-
end
|
82
|
-
when 6
|
83
|
-
param = parse_write_register_func(req)
|
84
|
-
if param[:err] == 0
|
85
|
-
@holding_registers[param[:addr]] = param[:val]
|
86
|
-
res = func.chr + req
|
87
|
-
end
|
88
|
-
when 15
|
89
|
-
param = parse_write_multiple_coils_func(req)
|
90
|
-
if param[:err] == 0
|
91
|
-
@coils[param[:addr],param[:quant]] = param[:val][0,param[:quant]]
|
92
|
-
res = func.chr + req
|
93
|
-
end
|
94
|
-
when 16
|
95
|
-
param = parse_write_multiple_registers_func(req)
|
96
|
-
if param[:err] == 0
|
97
|
-
@holding_registers[param[:addr],param[:quant]] = param[:val][0,param[:quant]]
|
98
|
-
res = func.chr + req
|
99
|
-
end
|
100
|
-
end
|
101
|
-
if param[:err] == 0
|
102
|
-
resp = tr + "\0\0" + (res.size + 1).to_word + @uid.chr + res
|
103
|
-
else
|
104
|
-
resp = tr + "\0\0\0\3" + @uid.chr + (func | 0x80).chr + param[:err].chr
|
105
|
-
end
|
106
|
-
io.write resp
|
107
|
-
end
|
58
|
+
io.write tr + "\0\0" + (pdu.size + 1).to_word + @uid.chr + pdu
|
108
59
|
end
|
109
|
-
|
110
|
-
private
|
111
|
-
|
112
|
-
def parse_read_func(req, field)
|
113
|
-
quant = req[3,2].unpack('n')[0]
|
114
|
-
|
115
|
-
return { :err => 3} unless quant <= 0x7d
|
116
|
-
|
117
|
-
addr = req[1,2].unpack('n')[0]
|
118
|
-
return { :err => 2 } unless addr + quant <= field.size
|
119
|
-
|
120
|
-
return { :err => 0, :quant => quant, :addr => addr }
|
121
|
-
end
|
122
|
-
|
123
|
-
def parse_write_coil_func(req)
|
124
|
-
addr = req[1,2].unpack('n')[0]
|
125
|
-
return { :err => 2 } unless addr <= @coils.size
|
126
|
-
|
127
|
-
val = req[3,2].unpack('n')[0]
|
128
|
-
return { :err => 3 } unless val == 0 or val == 0xff00
|
129
|
-
|
130
|
-
val = 1 if val == 0xff00
|
131
|
-
return { :err => 0, :addr => addr, :val => val }
|
132
60
|
end
|
133
|
-
|
134
|
-
def parse_write_register_func(req)
|
135
|
-
addr = req[1,2].unpack('n')[0]
|
136
|
-
return { :err => 2 } unless addr <= @coils.size
|
137
|
-
|
138
|
-
val = req[3,2].unpack('n')[0]
|
139
|
-
|
140
|
-
return { :err => 0, :addr => addr, :val => val }
|
141
|
-
end
|
142
|
-
|
143
|
-
def parse_write_multiple_coils_func(req)
|
144
|
-
param = parse_read_func(req, @coils)
|
145
|
-
|
146
|
-
if param[:err] == 0
|
147
|
-
param = {:err => 0, :addr => param[:addr], :quant => param[:quant], :val => req[6,param[:quant]].unpack_bits }
|
148
|
-
end
|
149
|
-
param
|
150
|
-
end
|
151
|
-
|
152
|
-
def parse_write_multiple_registers_func(req)
|
153
|
-
param = parse_read_func(req, @holding_registers)
|
154
|
-
|
155
|
-
if param[:err] == 0
|
156
|
-
param = {:err => 0, :addr => param[:addr], :quant => param[:quant], :val => req[6,param[:quant] * 2].unpack('n*')}
|
157
|
-
end
|
158
|
-
param
|
159
|
-
end
|
160
|
-
|
161
|
-
end
|
61
|
+
end
|
162
62
|
end
|
data/lib/rmodbus.rb
CHANGED
@@ -0,0 +1,117 @@
|
|
1
|
+
require 'rmodbus'
|
2
|
+
|
3
|
+
include ModBus
|
4
|
+
include ModBus::Errors
|
5
|
+
|
6
|
+
describe ModBus::TCPClient do
|
7
|
+
|
8
|
+
before(:all) do
|
9
|
+
@srv = ModBus::TCPServer.new(1502, 1)
|
10
|
+
@srv.coils = [0] * 8
|
11
|
+
@srv.discrete_inputs = [0] * 8
|
12
|
+
@srv.holding_registers = [0] * 8
|
13
|
+
@srv.input_registers = [0] * 8
|
14
|
+
@srv.start
|
15
|
+
|
16
|
+
@cl =TCPClient.new('127.0.0.1', 1502, 1)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Read coil status
|
20
|
+
it "should read coil status" do
|
21
|
+
@cl.read_coils(0, 4).should == [0] * 4
|
22
|
+
end
|
23
|
+
|
24
|
+
it "should raise exception if illegal data address" do
|
25
|
+
lambda { @cl.read_coils(501, 34) }.should raise_error(IllegalDataAddress)
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should raise exception if too many data" do
|
29
|
+
lambda { @cl.read_coils(0, 0x07D1) }.should raise_error(IllegalDataValue)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Read input status
|
33
|
+
it "should read discrete inputs" do
|
34
|
+
@cl.read_discrete_inputs(0, 4).should == [0] * 4
|
35
|
+
end
|
36
|
+
|
37
|
+
it "should raise exception if illegal data address" do
|
38
|
+
lambda { @cl.read_discrete_inputs(50, 23) }.should raise_error(IllegalDataAddress)
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should raise exception if too many data" do
|
42
|
+
lambda { @cl.read_discrete_inputs(0, 0x07D1) }.should raise_error(IllegalDataValue)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Read holding registers
|
46
|
+
it "should read discrete inputs" do
|
47
|
+
@cl.read_holding_registers(0, 4).should == [0, 0, 0, 0]
|
48
|
+
end
|
49
|
+
|
50
|
+
it "should raise exception if illegal data address" do
|
51
|
+
lambda { @cl.read_holding_registers(402, 99) }.should raise_error(IllegalDataAddress)
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
it "should raise exception if too many data" do
|
56
|
+
lambda { @cl.read_holding_registers(0, 0x007E) }.should raise_error(IllegalDataValue)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Read input registers
|
60
|
+
it "should read discrete inputs" do
|
61
|
+
@cl.read_input_registers(0, 4).should == [0, 0, 0, 0]
|
62
|
+
end
|
63
|
+
|
64
|
+
it "should raise exception if illegal data address" do
|
65
|
+
lambda { @cl.read_input_registers(402, 9) }.should raise_error(IllegalDataAddress)
|
66
|
+
end
|
67
|
+
|
68
|
+
it "should raise exception if too many data" do
|
69
|
+
lambda { @cl.read_input_registers(0, 0x007E) }.should raise_error(IllegalDataValue)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Force single coil
|
73
|
+
it "should force single coil" do
|
74
|
+
@cl.write_single_coil(4, 1).should == @cl
|
75
|
+
@cl.read_coils(4, 4).should == [1, 0, 0, 0]
|
76
|
+
end
|
77
|
+
|
78
|
+
it "should raise exception if illegal data address" do
|
79
|
+
lambda { @cl.write_single_coil(501, true) }.should raise_error(IllegalDataAddress)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Preset single register
|
83
|
+
it "should preset single register" do
|
84
|
+
@cl.write_single_register(4, 0x0AA0).should == @cl
|
85
|
+
@cl.read_holding_registers(4, 1).should == [0x0AA0]
|
86
|
+
end
|
87
|
+
|
88
|
+
it "should raise exception if illegal data address" do
|
89
|
+
lambda { @cl.write_single_register(501, 0x0AA0) }.should raise_error(IllegalDataAddress)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Force multiple coils
|
93
|
+
it "should force multiple coils" do
|
94
|
+
@cl.write_multiple_coils(4, [0,1,0,1]).should == @cl
|
95
|
+
@cl.read_coils(3, 5).should == [0,0,1,0,1]
|
96
|
+
end
|
97
|
+
|
98
|
+
it "should raise exception if illegal data address" do
|
99
|
+
lambda { @cl.write_multiple_coils(501, [1,0]) }.should raise_error(IllegalDataAddress)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Preset multiple registers
|
103
|
+
it "should preset multiple registers" do
|
104
|
+
@cl.write_multiple_registers(4, [1, 2, 3, 0xAACC]).should == @cl
|
105
|
+
@cl.read_holding_registers(3, 5).should == [0, 1, 2, 3, 0xAACC]
|
106
|
+
end
|
107
|
+
|
108
|
+
it "should raise exception if illegal data address" do
|
109
|
+
lambda { @cl.write_multiple_registers(501, [1, 2]) }.should raise_error(IllegalDataAddress)
|
110
|
+
end
|
111
|
+
|
112
|
+
after(:all) do
|
113
|
+
@cl.close unless @cl.closed?
|
114
|
+
@srv.stop
|
115
|
+
end
|
116
|
+
|
117
|
+
end
|
data/spec/ext_spec.rb
CHANGED
@@ -7,7 +7,11 @@ describe Array do
|
|
7
7
|
end
|
8
8
|
|
9
9
|
it "should return string reprisent 16bit" do
|
10
|
-
@arr.pack_to_word == "\xcd\x6b\x5"
|
10
|
+
@arr.pack_to_word.should == "\xcd\x6b\x5"
|
11
|
+
end
|
12
|
+
|
13
|
+
it "fixed bug for divisible 8 data " do
|
14
|
+
([0] * 8).pack_to_word.should == "\x00"
|
11
15
|
end
|
12
16
|
|
13
17
|
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'rmodbus'
|
2
|
+
|
3
|
+
include ModBus
|
4
|
+
|
5
|
+
describe TCPClient do
|
6
|
+
|
7
|
+
UID = 1
|
8
|
+
|
9
|
+
before(:each) do
|
10
|
+
@sock = mock("Socket")
|
11
|
+
@adu = "\000\001\000\000\000\001\001"
|
12
|
+
|
13
|
+
TCPSocket.should_receive(:new).with('127.0.0.1', 1502).and_return(@sock)
|
14
|
+
@sock.stub!(:read).with(0).and_return('')
|
15
|
+
|
16
|
+
@mb_client = TCPClient.new('127.0.0.1', 1502)
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'should log rec\send bytes' do
|
20
|
+
request, response = "\x3\x0\x6b\x0\x3", "\x3\x6\x2\x2b\x0\x0\x0\x64"
|
21
|
+
mock_query(request,response)
|
22
|
+
@mb_client.debug = true
|
23
|
+
STDOUT.should_receive("<<").with("Tx (12 bytes): [00][01][00][00][00][06][01][03][00][6b][00][03]\n")
|
24
|
+
STDOUT.should_receive("<<").with("Rx (15 bytes): [00][01][00][00][00][09][01][03][06][02][2b][00][00][00][64]\n")
|
25
|
+
@mb_client.query(request)
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should don't logging if debug disable" do
|
29
|
+
request, response = "\x3\x0\x6b\x0\x3", "\x3\x6\x2\x2b\x0\x0\x0\x64"
|
30
|
+
mock_query(request,response)
|
31
|
+
@mb_client.query(request)
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
def mock_query(request, response)
|
36
|
+
@adu = TCPClient.transaction.next.to_word + "\x0\x0\x0\x9" + UID.chr + request
|
37
|
+
@sock.should_receive(:write).with(@adu[0,4] + "\0\6" + UID.chr + request)
|
38
|
+
@sock.should_receive(:read).with(7).and_return(@adu[0,7])
|
39
|
+
@sock.should_receive(:read).with(8).and_return(response)
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
describe RTUClient do
|
45
|
+
|
46
|
+
before do
|
47
|
+
@sp = mock('Serial port')
|
48
|
+
SerialPort.should_receive(:new).with("/dev/port1", 9600, 7, 2, SerialPort::ODD).and_return(@sp)
|
49
|
+
|
50
|
+
@sp.stub!(:read_timeout=)
|
51
|
+
|
52
|
+
@mb_client = RTUClient.new("/dev/port1", 9600, 1, :data_bits => 7, :stop_bits => 2, :parity => SerialPort::ODD)
|
53
|
+
@mb_client.read_retries = 0
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'should log rec\send bytes' do
|
57
|
+
request = "\x3\x0\x1\x0\x1"
|
58
|
+
@sp.should_receive(:write).with("\1#{request}\xd5\xca")
|
59
|
+
@sp.should_receive(:read).and_return("\x1\x3\x2\xff\xff\xb9\xf4")
|
60
|
+
|
61
|
+
@mb_client.debug = true
|
62
|
+
STDOUT.should_receive("<<").with("Tx (8 bytes): [01][03][00][01][00][01][d5][ca]\n")
|
63
|
+
STDOUT.should_receive("<<").with("Rx (7 bytes): [01][03][02][ff][ff][b9][f4]\n")
|
64
|
+
|
65
|
+
@mb_client.query(request).should == "\xff\xff"
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
|
data/spec/rtu_client_spec.rb
CHANGED
@@ -9,33 +9,63 @@ include ModBus
|
|
9
9
|
describe RTUClient do
|
10
10
|
|
11
11
|
before do
|
12
|
-
@
|
13
|
-
SerialPort.should_receive(:new).with("/dev/port1", 9600).and_return(@
|
14
|
-
@
|
15
|
-
|
12
|
+
@sp = mock('Serial port')
|
13
|
+
SerialPort.should_receive(:new).with("/dev/port1", 9600, 8, 1, 0).and_return(@sp)
|
14
|
+
@sp.stub!(:read_timeout=)
|
15
|
+
|
16
|
+
@mb_client = RTUClient.new("/dev/port1", 9600, 1,
|
17
|
+
:data_bits => 8, :stop_bits => 1, :parity => SerialPort::NONE)
|
16
18
|
@mb_client.read_retries = 0
|
17
19
|
end
|
18
20
|
|
19
21
|
it "should ignore frame with other UID" do
|
20
22
|
request = "\x10\x0\x1\x0\x1\x2\xff\xff"
|
21
|
-
@
|
22
|
-
@
|
23
|
+
@sp.should_receive(:write).with("\1#{request}\xA6\x31")
|
24
|
+
@sp.should_receive(:read).and_return("\x2\x10\x0\x1\x0\x1\x1C\x08")
|
23
25
|
lambda {@mb_client.query(request)}.should raise_error(ModBus::Errors::ModBusTimeout)
|
24
26
|
end
|
25
27
|
|
26
28
|
it "should ignored frame with incorrect CRC" do
|
27
29
|
request = "\x10\x0\x1\x0\x1\x2\xff\xff"
|
28
|
-
@
|
29
|
-
@
|
30
|
+
@sp.should_receive(:write).with("\1#{request}\xA6\x31")
|
31
|
+
@sp.should_receive(:read).and_return("\x1\x10\x0\x1\x0\x1\x1C\x08")
|
30
32
|
lambda {@mb_client.query(request)}.should raise_error(ModBus::Errors::ModBusTimeout)
|
31
33
|
end
|
32
34
|
|
33
35
|
it "should return value of registers"do
|
34
36
|
request = "\x3\x0\x1\x0\x1"
|
35
|
-
@
|
36
|
-
@
|
37
|
+
@sp.should_receive(:write).with("\1#{request}\xd5\xca")
|
38
|
+
@sp.should_receive(:read).and_return("\x1\x3\x2\xff\xff\xb9\xf4")
|
37
39
|
@mb_client.query(request).should == "\xff\xff"
|
38
40
|
end
|
39
41
|
|
42
|
+
it 'should sugar connect method' do
|
43
|
+
port, baud, slave = "/dev/port1", 4800, 3
|
44
|
+
SerialPort.should_receive(:new).with(port, baud, 8, 1, SerialPort::NONE).and_return(@sp)
|
45
|
+
@sp.should_receive(:closed?).and_return(false)
|
46
|
+
@sp.should_receive(:close)
|
47
|
+
RTUClient.connect(port, baud, slave) do |cl|
|
48
|
+
cl.port.should == port
|
49
|
+
cl.baud.should == baud
|
50
|
+
cl.slave.should == slave
|
51
|
+
cl.data_bits.should == 8
|
52
|
+
cl.stop_bits.should == 1
|
53
|
+
cl.parity.should == SerialPort::NONE
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'should have closed? method' do
|
58
|
+
@sp.should_receive(:closed?).and_return(false)
|
59
|
+
@mb_client.closed?.should == false
|
60
|
+
|
61
|
+
@sp.should_receive(:closed?).and_return(false)
|
62
|
+
@sp.should_receive(:close)
|
63
|
+
|
64
|
+
@mb_client.close
|
65
|
+
|
66
|
+
@sp.should_receive(:closed?).and_return(true)
|
67
|
+
@mb_client.closed?.should == true
|
68
|
+
end
|
69
|
+
|
40
70
|
end
|
41
71
|
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'rmodbus'
|
2
|
+
|
3
|
+
include ModBus
|
4
|
+
|
5
|
+
describe RTUServer do
|
6
|
+
before do
|
7
|
+
@sp = mock "SerialPort"
|
8
|
+
SerialPort.should_receive(:new).with('/dev/ttyS0', 4800, 7, 2, SerialPort::NONE).and_return(@sp)
|
9
|
+
@sp.stub!(:read_timeout=)
|
10
|
+
|
11
|
+
@server = RTUServer.new('/dev/ttyS0', 4800, 1, :data_bits => 7, :stop_bits => 2)
|
12
|
+
@server.coils = [1,0,1,1]
|
13
|
+
@server.discrete_inputs = [1,1,0,0]
|
14
|
+
@server.holding_registers = [1,2,3,4]
|
15
|
+
@server.input_registers = [1,2,3,4]
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should be valid initialized " do
|
19
|
+
@server.coils.should == [1,0,1,1]
|
20
|
+
@server.discrete_inputs.should == [1,1,0,0]
|
21
|
+
@server.holding_registers.should == [1,2,3,4]
|
22
|
+
@server.input_registers.should == [1,2,3,4]
|
23
|
+
|
24
|
+
@server.port.should == '/dev/ttyS0'
|
25
|
+
@server.baud.should == 4800
|
26
|
+
@server.data_bits.should == 7
|
27
|
+
@server.stop_bits.should == 2
|
28
|
+
@server.parity.should == SerialPort::NONE
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|