modbus-cli 0.0.1
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/.gitignore +5 -0
- data/Gemfile +4 -0
- data/README.markdown +212 -0
- data/Rakefile +7 -0
- data/bin/modbus +7 -0
- data/lib/modbus-cli.rb +33 -0
- data/lib/modbus-cli/commands_common.rb +130 -0
- data/lib/modbus-cli/dump_command.rb +64 -0
- data/lib/modbus-cli/read_command.rb +160 -0
- data/lib/modbus-cli/version.rb +5 -0
- data/lib/modbus-cli/write_command.rb +81 -0
- data/modbus-cli.gemspec +26 -0
- data/spec/dump_command_spec.rb +104 -0
- data/spec/modbus_cli_spec.rb +24 -0
- data/spec/read_command_spec.rb +156 -0
- data/spec/spec_helper.rb +48 -0
- data/spec/write_command_spec.rb +109 -0
- metadata +97 -0
data/Gemfile
ADDED
data/README.markdown
ADDED
@@ -0,0 +1,212 @@
|
|
1
|
+
modbus-cli
|
2
|
+
==========
|
3
|
+
|
4
|
+
modbus-cli is a command line utility that lets you read and write data using
|
5
|
+
the Modbus TCP protocol (ethernet only, no serial line). It supports different
|
6
|
+
data formats (bool, int, word, float, dword), allows you to save data to a file
|
7
|
+
and dump it back to your device, acting as a backup tool, or allowing you to
|
8
|
+
move blocks in memory.
|
9
|
+
|
10
|
+
[Home page]:http://www.github.com/tallakt/momdbus-cli
|
11
|
+
|
12
|
+
Installation
|
13
|
+
------------
|
14
|
+
|
15
|
+
Install ruby. Then install the gem:
|
16
|
+
|
17
|
+
$ gem install modbus-cli
|
18
|
+
|
19
|
+
The pure ruby gem should run on most rubies.
|
20
|
+
|
21
|
+
Quick Start
|
22
|
+
-----------
|
23
|
+
|
24
|
+
Lets start by reading five words from our device startig from address %MW100.
|
25
|
+
|
26
|
+
$ modbus read 192.168.0.1 %MW100 5
|
27
|
+
|
28
|
+
which writes
|
29
|
+
|
30
|
+
%MW100 0
|
31
|
+
%MW101 0
|
32
|
+
%MW102 0
|
33
|
+
%MW103 0
|
34
|
+
%MW104 0
|
35
|
+
|
36
|
+
We chose to write the address in Schneider format, %MW100, but you can also use Modicon naming convention.
|
37
|
+
The following achieves the same as the previous line
|
38
|
+
|
39
|
+
$ modbus read 192.168.0.1 400101 5
|
40
|
+
|
41
|
+
To read coils run the command
|
42
|
+
|
43
|
+
$ modbus read 192.168.0.1 %M100 5
|
44
|
+
|
45
|
+
or
|
46
|
+
|
47
|
+
$ modbus read 192.168.0.1 101 5
|
48
|
+
|
49
|
+
You get three subcommands, read, write and dump. The dump commands writes data previously read
|
50
|
+
using the read command back to its original location. You can get more info on the commands by
|
51
|
+
using the help parameter
|
52
|
+
|
53
|
+
$ modbus read --help
|
54
|
+
|
55
|
+
To write data, write the values after the offset
|
56
|
+
|
57
|
+
$ modbus write 192.168.0.1 101 1 2 3 4 5
|
58
|
+
|
59
|
+
Please be aware that there is no protection here - you could easily mess up a running production
|
60
|
+
system by doing this.
|
61
|
+
|
62
|
+
Data Types
|
63
|
+
----------
|
64
|
+
|
65
|
+
For Schneider format you can choose between different data types by using different addresses,
|
66
|
+
When using Modicon addresses, you may specify the data type with an additional parameter.
|
67
|
+
The supported data types are shown in the following table
|
68
|
+
|
69
|
+
<table>
|
70
|
+
<tr>
|
71
|
+
<th>Data type</th>
|
72
|
+
<th>Data size</th>
|
73
|
+
<th>Schneider address</th>
|
74
|
+
<th>Modicon address</th>
|
75
|
+
<th>Parameter</th>
|
76
|
+
</tr>
|
77
|
+
<tr>
|
78
|
+
<td>word (default, unsigned)</td>
|
79
|
+
<td>16 bits</td>
|
80
|
+
<td>%MW100</td>
|
81
|
+
<td>400101</td>
|
82
|
+
<td>--word</td>
|
83
|
+
</tr>
|
84
|
+
<tr>
|
85
|
+
<td>integer (signed)</td>
|
86
|
+
<td>16 bits</td>
|
87
|
+
<td>%MW100</td>
|
88
|
+
<td>400101</td>
|
89
|
+
<td>--int</td>
|
90
|
+
</tr>
|
91
|
+
<tr>
|
92
|
+
<td>floating point</td>
|
93
|
+
<td>32 bits</td>
|
94
|
+
<td>%MF100</td>
|
95
|
+
<td>400101</td>
|
96
|
+
<td>--float</td>
|
97
|
+
</tr>
|
98
|
+
<tr>
|
99
|
+
<td>double word</td>
|
100
|
+
<td>32 bits</td>
|
101
|
+
<td>%MD100</td>
|
102
|
+
<td>400101</td>
|
103
|
+
<td>--dword</td>
|
104
|
+
</tr>
|
105
|
+
<tr>
|
106
|
+
<td>boolean (coils)</td>
|
107
|
+
<td>1 bit</td>
|
108
|
+
<td>%M100</td>
|
109
|
+
<td>101</td>
|
110
|
+
<td>N/A</td>
|
111
|
+
</tr>
|
112
|
+
</table>
|
113
|
+
|
114
|
+
To read a floating point value, issue the following command
|
115
|
+
|
116
|
+
$ modbus read %MF100 2
|
117
|
+
|
118
|
+
which should give you something like
|
119
|
+
|
120
|
+
%MF100 0.0
|
121
|
+
%MF102 0.0
|
122
|
+
|
123
|
+
or alternatively
|
124
|
+
|
125
|
+
$ modbus read --float 400101 2
|
126
|
+
|
127
|
+
giving
|
128
|
+
|
129
|
+
400101 0.0
|
130
|
+
400103 0.0
|
131
|
+
|
132
|
+
The modbus command supports the addressing areas 1..99999 for coils and 400001..499999 for the rest using Modicon addresses. Using Schneider addresses the %M addresses are in a separate memory from %MW values, but %MW, %MD, %MF all reside in a shared memory, so %MW0 and %MW1 share the memory with %MF0.
|
133
|
+
|
134
|
+
|
135
|
+
Reading and dumping to files
|
136
|
+
----------------------------
|
137
|
+
|
138
|
+
The following functionality has a few potential uses:
|
139
|
+
|
140
|
+
* Storing a backup of PLC memory containing setpoints and such in event og hardware failure
|
141
|
+
* Moving a block from one location in the PLC to another location
|
142
|
+
* Copy data from one machine to another
|
143
|
+
|
144
|
+
First, start by reading data from your device to be stored in a file
|
145
|
+
|
146
|
+
$ modbus read --output mybackup.yml 192.168.0.1 400001 1000
|
147
|
+
|
148
|
+
on Linux you may want to look at the text file by doing
|
149
|
+
|
150
|
+
$ less mybackup.yml
|
151
|
+
|
152
|
+
on Windows try loading the file in Wordpad.
|
153
|
+
|
154
|
+
To restore the memory at a later time, run the command (again a word of warning, this can mess
|
155
|
+
up a running production system)
|
156
|
+
|
157
|
+
$ modbus dump mybackup.yml
|
158
|
+
|
159
|
+
The modbus command supports multiple files, so feel free to write
|
160
|
+
|
161
|
+
$ modbus dump *.yml
|
162
|
+
|
163
|
+
To write the data back to a different device, use the --host parameter
|
164
|
+
|
165
|
+
$ modbus dump --host 192.168.0.2 mybackup.yml
|
166
|
+
|
167
|
+
or for a different memory location
|
168
|
+
|
169
|
+
$ modbus dump --offset 401001 192.168.0.2 mybackup.yml
|
170
|
+
|
171
|
+
or for a different slave id
|
172
|
+
|
173
|
+
$ modbus dump --slave 88 192.168.0.2 mybackup.yml
|
174
|
+
|
175
|
+
Slave ids are not commonly necessary when working with Modbus TCP.
|
176
|
+
|
177
|
+
Contributing to modbus-cli
|
178
|
+
--------------------------
|
179
|
+
|
180
|
+
Feel free to fork the project on GitHub and send fork requests. Please
|
181
|
+
try to have each feature separated in commits.
|
182
|
+
|
183
|
+
|
184
|
+
|
185
|
+
License
|
186
|
+
-------
|
187
|
+
|
188
|
+
(The MIT License)
|
189
|
+
|
190
|
+
Copyright (C) 2011 Tallak Tveide
|
191
|
+
|
192
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
193
|
+
of this software and associated documentation files (the "Software"), to
|
194
|
+
deal in the Software without restriction, including without limitation the
|
195
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
196
|
+
sell copies of the Software, and to permit persons to whom the Software is
|
197
|
+
furnished to do so, subject to the following conditions:
|
198
|
+
|
199
|
+
The above copyright notice and this permission notice shall be included in
|
200
|
+
all copies or substantial portions of the Software.
|
201
|
+
|
202
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
203
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
204
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
205
|
+
THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
206
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
207
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
208
|
+
|
209
|
+
|
210
|
+
|
211
|
+
|
212
|
+
|
data/Rakefile
ADDED
data/bin/modbus
ADDED
data/lib/modbus-cli.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'clamp'
|
2
|
+
|
3
|
+
# do it this way to not have serialport warning on startup
|
4
|
+
# require 'rmodbus'
|
5
|
+
require 'rmodbus/errors'
|
6
|
+
require 'rmodbus/ext'
|
7
|
+
require 'rmodbus/debug'
|
8
|
+
require 'rmodbus/options'
|
9
|
+
require 'rmodbus/rtu'
|
10
|
+
require 'rmodbus/tcp'
|
11
|
+
require 'rmodbus/slave'
|
12
|
+
require 'rmodbus/client'
|
13
|
+
require 'rmodbus/server'
|
14
|
+
require 'rmodbus/tcp_slave'
|
15
|
+
require 'rmodbus/tcp_client'
|
16
|
+
require 'rmodbus/tcp_server'
|
17
|
+
|
18
|
+
require 'modbus-cli/version'
|
19
|
+
require 'modbus-cli/read_command'
|
20
|
+
require 'modbus-cli/write_command'
|
21
|
+
require 'modbus-cli/dump_command'
|
22
|
+
|
23
|
+
module Modbus
|
24
|
+
module Cli
|
25
|
+
DEFAULT_SLAVE = 1
|
26
|
+
|
27
|
+
class CommandLineRunner < Clamp::Command
|
28
|
+
subcommand 'read', 'read from the device', ReadCommand
|
29
|
+
subcommand 'write', 'write to the device', WriteCommand
|
30
|
+
subcommand 'dump', 'copy contents of read file to the device', DumpCommand
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
module Modbus
|
2
|
+
module Cli
|
3
|
+
module CommandsCommon
|
4
|
+
|
5
|
+
MAX_WRITE_COILS = 1968
|
6
|
+
MAX_WRITE_WORDS = 123
|
7
|
+
DEFAULT_SLAVE = 1
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
|
11
|
+
def host_parameter
|
12
|
+
parameter 'HOST', 'IP address or hostname for the Modbus device', :attribute_name => :host
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
def address_parameter
|
17
|
+
parameter 'ADDRESS', 'Start address, in Schneider format (eg %M100, %MW100)', :attribute_name => :address do |a|
|
18
|
+
schneider_match(a) || modicon_match(a) || raise(ArgumentError, "Illegal address #{a}")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def datatype_options
|
23
|
+
option ["-w", "--word"], :flag, "use signed 16 bit integers"
|
24
|
+
option ["-i", "--int"], :flag, "use signed 16 bit integers"
|
25
|
+
option ["-d", "--dword"], :flag, "use signed 16 bit integers"
|
26
|
+
option ["-f", "--float"], :flag, "use signed 16 bit integers"
|
27
|
+
end
|
28
|
+
|
29
|
+
def format_options
|
30
|
+
option ["--modicon"], :flag, "use Modicon addressing (eg. coil: 101, word: 400001)"
|
31
|
+
option ["--schneider"], :flag, "use Schneider addressing (eg. coil: %M100, word: %MW0, float: %MF0, dword: %MD0)"
|
32
|
+
end
|
33
|
+
|
34
|
+
def slave_option
|
35
|
+
option ["-s", "--slave"], 'ID', "use slave id ID", :default => 1 do |s|
|
36
|
+
Integer(s).tap {|slave| raise ArgumentError 'Slave must be in the range 0..255' unless (0..255).member?(slave) }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def output_option
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
def data_size
|
46
|
+
case addr_type
|
47
|
+
when :bit, :word, :int
|
48
|
+
1
|
49
|
+
when :float, :dword
|
50
|
+
2
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
def addr_offset
|
56
|
+
address[:offset]
|
57
|
+
end
|
58
|
+
|
59
|
+
def addr_type
|
60
|
+
if int?
|
61
|
+
:int
|
62
|
+
elsif dword?
|
63
|
+
:dword
|
64
|
+
elsif float?
|
65
|
+
:float
|
66
|
+
elsif word?
|
67
|
+
:word
|
68
|
+
else
|
69
|
+
address[:datatype]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
|
74
|
+
def schneider_match(address)
|
75
|
+
schneider_match = address.match /%M([FWD])?(\d+)/i
|
76
|
+
if schneider_match
|
77
|
+
{:offset => schneider_match[2].to_i, :format => :schneider}.tap do |result|
|
78
|
+
case schneider_match[1]
|
79
|
+
when nil
|
80
|
+
result[:datatype] = :bit
|
81
|
+
when 'W', 'w'
|
82
|
+
result[:datatype] = :word
|
83
|
+
when 'F', 'f'
|
84
|
+
result[:datatype] = :float
|
85
|
+
when 'D', 'd'
|
86
|
+
result[:datatype] = :dword
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
def modicon_match(address)
|
94
|
+
if address.match /^\d+$/
|
95
|
+
offset = address.to_i
|
96
|
+
case offset
|
97
|
+
when 1..99999
|
98
|
+
{:offset => offset - 1, :datatype => :bit, :format => :modicon}
|
99
|
+
when 400001..499999
|
100
|
+
{:offset => offset - 400001, :datatype => :word, :format => :modicon}
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def addr_format
|
106
|
+
if schneider?
|
107
|
+
:schneider
|
108
|
+
elsif modicon?
|
109
|
+
:modicon
|
110
|
+
else
|
111
|
+
address[:format]
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def sliced_write_registers(sl, offset, data)
|
116
|
+
(0..(data.count - 1)).each_slice(MAX_WRITE_WORDS) do |slice|
|
117
|
+
result = sl.write_holding_registers(slice.first + offset, data.values_at(*slice))
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def sliced_write_coils(sl, offset, data)
|
122
|
+
(0..(data.count - 1)).each_slice(MAX_WRITE_COILS) do |slice|
|
123
|
+
result = sl.write_multiple_coils(slice.first + offset, data.values_at(*slice))
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'modbus-cli/commands_common'
|
2
|
+
require 'yaml'
|
3
|
+
|
4
|
+
module Modbus
|
5
|
+
module Cli
|
6
|
+
class DumpCommand < Clamp::Command
|
7
|
+
extend CommandsCommon::ClassMethods
|
8
|
+
include CommandsCommon
|
9
|
+
|
10
|
+
parameter 'FILES ...', 'restore data in FILES to original devices (created by modbus read command)', :attribute_name => :files do |f|
|
11
|
+
f.map do |filename|
|
12
|
+
YAML.load_file(filename).dup.tap do |ff|
|
13
|
+
#parameter takes presedence
|
14
|
+
ff[:host] = host || ff[:host]
|
15
|
+
ff[:slave] = slave || ff[:slave]
|
16
|
+
ff[:offset] = offset || ff[:offset]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
option ["-h", "--host"], 'ADDR', "use the address/hostname ADDR instead of the stored one"
|
22
|
+
|
23
|
+
option ["-s", "--slave"], 'ID', "use slave ID instead of the stored one" do |s|
|
24
|
+
Integer(s).tap {|slave| raise ArgumentError 'Slave address should be in the range 0..255' unless (0..255).member? slave }
|
25
|
+
end
|
26
|
+
|
27
|
+
option ["-o", "--offset"], 'OFFSET', "start writing at address OFFSET instead of original location" do |o|
|
28
|
+
raise ArgumentError 'Illegal offset address: ' + o unless modicon_match(o) || schneider_match(o)
|
29
|
+
o
|
30
|
+
end
|
31
|
+
|
32
|
+
def execute
|
33
|
+
host_ids = files.map {|d| d[:host] }.sort.uniq
|
34
|
+
host_ids.each {|host_id| execute_host host_id }
|
35
|
+
end
|
36
|
+
|
37
|
+
def execute_host(host_id)
|
38
|
+
slave_ids = files.select {|d| d[:host] == host_id }.map {|d| d[:slave] }.sort.uniq
|
39
|
+
ModBus::TCPClient.connect(host_id) do |client|
|
40
|
+
slave_ids.each {|slave_id| execute_slave host_id, slave_id, client }
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def execute_slave(host_id, slave_id, client)
|
45
|
+
client.with_slave(slave_id) do |slave|
|
46
|
+
files.select {|d| d[:host] == host_id && d[:slave] == slave_id }.each do |file_data|
|
47
|
+
execute_single_file slave, file_data
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def execute_single_file(slave, file_data)
|
53
|
+
address = modicon_match(file_data[:offset].to_s) || schneider_match(file_data[:offset].to_s)
|
54
|
+
case address[:datatype]
|
55
|
+
when :bit
|
56
|
+
sliced_write_coils slave, address[:offset], file_data[:data]
|
57
|
+
when :word
|
58
|
+
sliced_write_registers slave, address[:offset], file_data[:data]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
@@ -0,0 +1,160 @@
|
|
1
|
+
require 'modbus-cli/commands_common'
|
2
|
+
require 'yaml'
|
3
|
+
|
4
|
+
module Modbus
|
5
|
+
module Cli
|
6
|
+
class ReadCommand < Clamp::Command
|
7
|
+
extend CommandsCommon::ClassMethods
|
8
|
+
include CommandsCommon
|
9
|
+
|
10
|
+
MAX_READ_COIL_COUNT = 2000
|
11
|
+
MAX_READ_WORD_COUNT = 125
|
12
|
+
|
13
|
+
datatype_options
|
14
|
+
format_options
|
15
|
+
slave_option
|
16
|
+
host_parameter
|
17
|
+
address_parameter
|
18
|
+
option ["-o", "--output"], 'FILE', "write results to file FILE"
|
19
|
+
|
20
|
+
parameter 'COUNT', 'number of data to read', :attribute_name => :count do |c|
|
21
|
+
result = Integer(c)
|
22
|
+
raise ArgumentError, 'Count must be positive' if result <= 0
|
23
|
+
result
|
24
|
+
|
25
|
+
end
|
26
|
+
|
27
|
+
def read_floats(sl)
|
28
|
+
floats = read_and_unpack(sl, 'g')
|
29
|
+
(0...count).each do |n|
|
30
|
+
puts "#{ '%-10s' % address_to_s(addr_offset + n * data_size)} #{nice_float('% 16.8f' % floats[n])}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def read_dwords(sl)
|
35
|
+
dwords = read_and_unpack(sl, 'N')
|
36
|
+
(0...count).each do |n|
|
37
|
+
puts "#{ '%-10s' % address_to_s(addr_offset + n * data_size)} #{'%10d' % dwords[n]}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def read_registers(sl, options = {})
|
42
|
+
data = read_data_words(sl)
|
43
|
+
if options[:int]
|
44
|
+
data = data.pack('S').unpack('s')
|
45
|
+
end
|
46
|
+
read_range.zip(data).each do |pair|
|
47
|
+
puts "#{ '%-10s' % address_to_s(pair.first)} #{'%6d' % pair.last}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def read_words_to_file(sl)
|
52
|
+
write_data_to_file(read_data_words(sl))
|
53
|
+
end
|
54
|
+
|
55
|
+
def read_coils_to_file(sl)
|
56
|
+
write_data_to_file(read_data_coils(sl))
|
57
|
+
end
|
58
|
+
|
59
|
+
def write_data_to_file(data)
|
60
|
+
File.open(output, 'w') do |file|
|
61
|
+
file.puts({ :host => host, :slave => slave, :offset => address_to_s(addr_offset, :modicon), :data => data }.to_yaml)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def read_coils(sl)
|
66
|
+
data = read_data_coils(sl)
|
67
|
+
read_range.zip(data) do |pair|
|
68
|
+
puts "#{ '%-10s' % address_to_s(pair.first)} #{'%d' % pair.last}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def execute
|
73
|
+
ModBus::TCPClient.connect(host) do |cl|
|
74
|
+
cl.with_slave(slave) do |sl|
|
75
|
+
if output then
|
76
|
+
case addr_type
|
77
|
+
when :bit
|
78
|
+
read_coils_to_file(sl)
|
79
|
+
else
|
80
|
+
read_words_to_file(sl)
|
81
|
+
end
|
82
|
+
else
|
83
|
+
case addr_type
|
84
|
+
when :bit
|
85
|
+
read_coils(sl)
|
86
|
+
when :int
|
87
|
+
read_registers(sl, :int => true)
|
88
|
+
when :word
|
89
|
+
read_registers(sl)
|
90
|
+
when :float
|
91
|
+
read_floats(sl)
|
92
|
+
when :dword
|
93
|
+
read_dwords(sl)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def read_and_unpack(sl, format)
|
101
|
+
# the word ordering is wrong. calling reverse two times effectively swaps every pair
|
102
|
+
read_data_words(sl).reverse.pack('n*').unpack("#{format}*").reverse
|
103
|
+
end
|
104
|
+
|
105
|
+
def read_data_words(sl)
|
106
|
+
result = []
|
107
|
+
read_range.each_slice(MAX_READ_WORD_COUNT) {|slice| result += sl.read_holding_registers(slice.first, slice.count) }
|
108
|
+
result
|
109
|
+
end
|
110
|
+
|
111
|
+
|
112
|
+
def read_data_coils(sl)
|
113
|
+
result = []
|
114
|
+
read_range.each_slice(MAX_READ_COIL_COUNT) do |slice|
|
115
|
+
result += sl.read_coils(slice.first, slice.count)
|
116
|
+
end
|
117
|
+
result
|
118
|
+
end
|
119
|
+
|
120
|
+
def read_range
|
121
|
+
(addr_offset..(addr_offset + count * data_size - 1))
|
122
|
+
end
|
123
|
+
|
124
|
+
|
125
|
+
def nice_float(str)
|
126
|
+
m = str.match /(.*[.][0-9])0*/
|
127
|
+
if m
|
128
|
+
m[1]
|
129
|
+
else
|
130
|
+
str
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def address_to_s(addr, format = addr_format)
|
135
|
+
case format
|
136
|
+
when :schneider
|
137
|
+
case addr_type
|
138
|
+
when :bit
|
139
|
+
'%M' + addr.to_s
|
140
|
+
when :word, :int
|
141
|
+
'%MW' + addr.to_s
|
142
|
+
when :dword
|
143
|
+
'%MD' + addr.to_s
|
144
|
+
when :float
|
145
|
+
'%MF' + addr.to_s
|
146
|
+
end
|
147
|
+
when :modicon
|
148
|
+
case addr_type
|
149
|
+
when :bit
|
150
|
+
(addr + 1).to_s
|
151
|
+
when :word, :int
|
152
|
+
(addr + 400001).to_s
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'modbus-cli/commands_common'
|
2
|
+
|
3
|
+
module Modbus
|
4
|
+
module Cli
|
5
|
+
class WriteCommand < Clamp::Command
|
6
|
+
extend CommandsCommon::ClassMethods
|
7
|
+
include CommandsCommon
|
8
|
+
|
9
|
+
|
10
|
+
datatype_options
|
11
|
+
format_options
|
12
|
+
slave_option
|
13
|
+
host_parameter
|
14
|
+
address_parameter
|
15
|
+
|
16
|
+
parameter 'VALUES ...', 'values to write, nonzero counts as true for discrete values', :attribute_name => :values do |vv|
|
17
|
+
case addr_type
|
18
|
+
|
19
|
+
when :bit
|
20
|
+
int_parameter vv, 0, 1
|
21
|
+
when :word
|
22
|
+
int_parameter vv, 0, 0xffff
|
23
|
+
when :int
|
24
|
+
int_parameter vv, -32768, 32767
|
25
|
+
when :dword
|
26
|
+
int_parameter vv, 0, 0xffffffff
|
27
|
+
when :float
|
28
|
+
vv.map {|v| Float(v) }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
|
34
|
+
def execute
|
35
|
+
ModBus::TCPClient.connect(host) do |cl|
|
36
|
+
cl.with_slave(slave) do |sl|
|
37
|
+
case addr_type
|
38
|
+
when :bit
|
39
|
+
write_coils sl
|
40
|
+
when :word, :int
|
41
|
+
write_words sl
|
42
|
+
when :float
|
43
|
+
write_floats sl
|
44
|
+
when :dword
|
45
|
+
write_dwords sl
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def write_coils(sl)
|
52
|
+
sliced_write_coils sl, addr_offset, values
|
53
|
+
end
|
54
|
+
|
55
|
+
def write_words(sl)
|
56
|
+
sliced_write_registers sl, addr_offset, values.pack('S*').unpack('S*')
|
57
|
+
end
|
58
|
+
|
59
|
+
def write_floats(sl)
|
60
|
+
pack_and_write sl, 'g'
|
61
|
+
end
|
62
|
+
|
63
|
+
def write_dwords(sl)
|
64
|
+
pack_and_write sl, 'N'
|
65
|
+
end
|
66
|
+
|
67
|
+
def pack_and_write(sl, format)
|
68
|
+
# the word ordering is wrong. calling reverse two times effectively swaps every pair
|
69
|
+
sliced_write_registers(sl, addr_offset, values.reverse.pack("#{format}*").unpack('n*').reverse)
|
70
|
+
end
|
71
|
+
|
72
|
+
def int_parameter(vv, min, max)
|
73
|
+
vv.map {|v| Integer(v) }.tap do |values|
|
74
|
+
values.each do |v|
|
75
|
+
raise ArgumentError, "Value should be in the range #{min}..#{max}" unless (min..max).member? v
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
data/modbus-cli.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "modbus-cli/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "modbus-cli"
|
7
|
+
s.version = Modbus::Cli::VERSION
|
8
|
+
s.authors = ["Tallak Tveide"]
|
9
|
+
s.email = ["tallak@tveide.net"]
|
10
|
+
s.homepage = "http://www.github.com/tallakt/modbus-cli"
|
11
|
+
s.summary = %q{Modbus command line}
|
12
|
+
s.description = %q{Command line interface to communicate over Modbus TCP}
|
13
|
+
|
14
|
+
s.rubyforge_project = "modbus-cli"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
# specify any dependencies here; for example:
|
22
|
+
# s.add_development_dependency "rspec"
|
23
|
+
s.add_runtime_dependency "rmodbus", '= 1.1.0'
|
24
|
+
s.add_runtime_dependency "clamp", '= 0.3.0'
|
25
|
+
s.add_development_dependency "rspec", '= 2.7.0'
|
26
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
|
4
|
+
|
5
|
+
describe Modbus::Cli::DumpCommand do
|
6
|
+
before(:each) do
|
7
|
+
stub_tcpip
|
8
|
+
end
|
9
|
+
|
10
|
+
it 'reads the file and write the contents to the original device' do
|
11
|
+
client = mock 'client'
|
12
|
+
slave = mock 'slave'
|
13
|
+
YAML.should_receive(:load_file).with('dump.yml').and_return(:host => '1.2.3.4', :slave => 5, :offset => 400123, :data => [4, 5, 6])
|
14
|
+
ModBus::TCPClient.should_receive(:connect).with('1.2.3.4').and_yield(client)
|
15
|
+
client.should_receive(:with_slave).with(5).and_yield(slave)
|
16
|
+
slave.should_receive(:write_holding_registers).with(122, [4, 5, 6])
|
17
|
+
cmd.run %w(dump dump.yml)
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'can read two files from separate hosts' do
|
21
|
+
client1 = mock 'client1'
|
22
|
+
client2 = mock 'client2'
|
23
|
+
slave1 = mock 'slave1'
|
24
|
+
slave2 = mock 'slave2'
|
25
|
+
yml = {:host => 'X', :slave => 5, :offset => 400010, :data => [99]}
|
26
|
+
YAML.should_receive(:load_file).with('a.yml').and_return(yml)
|
27
|
+
YAML.should_receive(:load_file).with('b.yml').and_return(yml.dup.tap {|y| y[:host] = 'Y' })
|
28
|
+
ModBus::TCPClient.should_receive(:connect).with('X').and_yield(client1)
|
29
|
+
ModBus::TCPClient.should_receive(:connect).with('Y').and_yield(client2)
|
30
|
+
client1.should_receive(:with_slave).with(5).and_yield(slave1)
|
31
|
+
client2.should_receive(:with_slave).with(5).and_yield(slave2)
|
32
|
+
slave1.should_receive(:write_holding_registers).with(9, [99])
|
33
|
+
slave2.should_receive(:write_holding_registers).with(9, [99])
|
34
|
+
cmd.run %w(dump a.yml b.yml)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'can dump two files from separate slaves on same host' do
|
38
|
+
client1 = mock 'client1'
|
39
|
+
slave1 = mock 'slave1'
|
40
|
+
slave2 = mock 'slave2'
|
41
|
+
yml = {:host => 'X', :slave => 5, :offset => 400010, :data => [99]}
|
42
|
+
YAML.should_receive(:load_file).with('a.yml').and_return(yml)
|
43
|
+
YAML.should_receive(:load_file).with('b.yml').and_return(yml.dup.tap {|y| y[:slave] = 99 })
|
44
|
+
ModBus::TCPClient.should_receive(:connect).with('X').and_yield(client1)
|
45
|
+
client1.should_receive(:with_slave).with(5).and_yield(slave1)
|
46
|
+
client1.should_receive(:with_slave).with(99).and_yield(slave2)
|
47
|
+
slave1.should_receive(:write_holding_registers).with(9, [99])
|
48
|
+
slave2.should_receive(:write_holding_registers).with(9, [99])
|
49
|
+
cmd.run %w(dump a.yml b.yml)
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'can dump two files from one slave' do
|
53
|
+
client1 = mock 'client1'
|
54
|
+
slave1 = mock 'slave1'
|
55
|
+
yml = {:host => 'X', :slave => 5, :offset => 400010, :data => [99]}
|
56
|
+
YAML.should_receive(:load_file).with('a.yml').and_return(yml)
|
57
|
+
YAML.should_receive(:load_file).with('b.yml').and_return(yml.dup)
|
58
|
+
ModBus::TCPClient.should_receive(:connect).with('X').and_yield(client1)
|
59
|
+
client1.should_receive(:with_slave).with(5).and_yield(slave1)
|
60
|
+
slave1.should_receive(:write_holding_registers).with(9, [99])
|
61
|
+
slave1.should_receive(:write_holding_registers).with(9, [99])
|
62
|
+
cmd.run %w(dump a.yml b.yml)
|
63
|
+
end
|
64
|
+
|
65
|
+
it 'accepts the --host <hostname> parameter' do
|
66
|
+
YAML.should_receive(:load_file).with('dump.yml').and_return(:host => '1.2.3.4', :slave => 5, :offset => 123, :data => [4, 5, 6])
|
67
|
+
ModBus::TCPClient.should_receive(:connect).with('Y')
|
68
|
+
cmd.run %w(dump --host Y dump.yml)
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'accepts the --slave <id> parameter' do
|
72
|
+
client = mock 'client'
|
73
|
+
YAML.should_receive(:load_file).with('dump.yml').and_return(:host => '1.2.3.4', :slave => 5, :offset => 123, :data => [4, 5, 6])
|
74
|
+
ModBus::TCPClient.should_receive(:connect).with('1.2.3.4').and_yield(client)
|
75
|
+
client.should_receive(:with_slave).with(99)
|
76
|
+
cmd.run %w(dump --slave 99 dump.yml)
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'accepts the --offset <n> parameter with modicon addressing' do
|
80
|
+
client = mock 'client'
|
81
|
+
slave = mock 'slave'
|
82
|
+
YAML.should_receive(:load_file).with('dump.yml').and_return(:host => '1.2.3.4', :slave => 5, :offset => 123, :data => [4, 5, 6])
|
83
|
+
ModBus::TCPClient.should_receive(:connect).with('1.2.3.4').and_yield(client)
|
84
|
+
client.should_receive(:with_slave).with(5).and_yield(slave)
|
85
|
+
slave.should_receive(:write_holding_registers).with(100, [4, 5, 6])
|
86
|
+
cmd.run %w(dump --offset 400101 dump.yml)
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'accepts the --offset <n> parameter with schneider addressing' do
|
90
|
+
client = mock 'client'
|
91
|
+
slave = mock 'slave'
|
92
|
+
YAML.should_receive(:load_file).with('dump.yml').and_return(:host => '1.2.3.4', :slave => 5, :offset => 123, :data => [4, 5, 6])
|
93
|
+
ModBus::TCPClient.should_receive(:connect).with('1.2.3.4').and_yield(client)
|
94
|
+
client.should_receive(:with_slave).with(5).and_yield(slave)
|
95
|
+
slave.should_receive(:write_holding_registers).with(100, [4, 5, 6])
|
96
|
+
cmd.run %w(dump --offset %MW100 dump.yml)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
|
102
|
+
|
103
|
+
|
104
|
+
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
|
4
|
+
|
5
|
+
describe Modbus::Cli::CommandLineRunner do
|
6
|
+
include OutputCapture
|
7
|
+
|
8
|
+
before(:each) do
|
9
|
+
stub_tcpip
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'has help describing the read and write commands' do
|
13
|
+
c = cmd
|
14
|
+
Proc.new { c.run(%w(--help)) }.should raise_exception(Clamp::HelpWanted)
|
15
|
+
c.help.should match /usage:/i
|
16
|
+
c.help.should match /read/
|
17
|
+
c.help.should match /write/
|
18
|
+
c.help.should match /dump/
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
|
23
|
+
|
24
|
+
|
@@ -0,0 +1,156 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'yaml'
|
3
|
+
|
4
|
+
|
5
|
+
|
6
|
+
describe Modbus::Cli::ReadCommand do
|
7
|
+
include OutputCapture
|
8
|
+
|
9
|
+
before(:each) do
|
10
|
+
stub_tcpip
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'can read registers' do
|
14
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
15
|
+
slave.should_receive(:read_holding_registers).with(100, 10).and_return((0..9).to_a)
|
16
|
+
cmd.run %w(read 1.2.3.4 %MW100 10)
|
17
|
+
stdout.should match(/^\s*%MW105\s*5$/)
|
18
|
+
end
|
19
|
+
|
20
|
+
|
21
|
+
it 'can read floating point numbers' do
|
22
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
23
|
+
slave.should_receive(:read_holding_registers).with(100, 4).and_return([52429, 17095, 52429, 17095])
|
24
|
+
cmd.run %w(read 1.2.3.4 %MF100 2)
|
25
|
+
stdout.should match(/^\s*%MF102\s*99[.]9(00[0-9]*)?$/)
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'can read double word numbers' do
|
29
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
30
|
+
slave.should_receive(:read_holding_registers).with(100, 4).and_return([16959, 15, 16959, 15])
|
31
|
+
cmd.run %w(read 1.2.3.4 %MD100 2)
|
32
|
+
stdout.should match(/^\s*%MD102\s*999999$/)
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'can read coils' do
|
36
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
37
|
+
slave.should_receive(:read_coils).with(100, 10).and_return([1, 0] * 5)
|
38
|
+
cmd.run %w(read 1.2.3.4 %M100 10)
|
39
|
+
stdout.should match(/^\s*%M105\s*0$/)
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
|
44
|
+
|
45
|
+
it 'rejects illegal counts' do
|
46
|
+
lambda { cmd.run %w(read 1.2.3.4 %MW100 1+0) }.should raise_exception(Clamp::UsageError)
|
47
|
+
lambda { cmd.run %w(read 1.2.3.4 %MW100 -10) }.should raise_exception(Clamp::UsageError)
|
48
|
+
lambda { cmd.run %w(read 1.2.3.4 %MW100 9.9) }.should raise_exception(Clamp::UsageError)
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'rejects illegal addresses' do
|
52
|
+
lambda { cmd.run %w(read 1.2.3.4 %MW1+00) }.should raise_exception(Clamp::UsageError)
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'should split large reads into smaller chunks for words' do
|
56
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
57
|
+
slave.should_receive(:read_holding_registers).with(100, 125).and_return([1, 0] * 1000)
|
58
|
+
slave.should_receive(:read_holding_registers).with(225, 25).and_return([1, 0] * 1000)
|
59
|
+
cmd.run %w(read 1.2.3.4 %MW100 150)
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'should split large reads into smaller chunks for coils' do
|
63
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
64
|
+
slave.should_receive(:read_coils).with(100, 2000).and_return([1, 0] * 1000)
|
65
|
+
slave.should_receive(:read_coils).with(2100, 1000).and_return([1, 0] * 500)
|
66
|
+
cmd.run %w(read 1.2.3.4 %M100 3000)
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'can read registers as ints' do
|
70
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
71
|
+
slave.should_receive(:read_holding_registers).with(100, 1).and_return([0xffff])
|
72
|
+
cmd.run %w(read --int 1.2.3.4 %MW100 1)
|
73
|
+
stdout.should match(/^\s*%MW100\s*-1$/)
|
74
|
+
end
|
75
|
+
|
76
|
+
it 'can read registers as floats' do
|
77
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
78
|
+
slave.should_receive(:read_holding_registers).with(100, 2).and_return([0,0])
|
79
|
+
cmd.run %w(read --float 1.2.3.4 %MW100 1)
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'can read registers as dwords' do
|
83
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
84
|
+
slave.should_receive(:read_holding_registers).with(100, 2).and_return([0,0])
|
85
|
+
cmd.run %w(read --dword 1.2.3.4 %MW100 1)
|
86
|
+
end
|
87
|
+
|
88
|
+
it 'can read registers as words' do
|
89
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
90
|
+
slave.should_receive(:read_holding_registers).with(100, 1).and_return([0])
|
91
|
+
cmd.run %w(read --word 1.2.3.4 %MD100 1)
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'accepts Modicon addresses for coils' do
|
95
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
96
|
+
slave.should_receive(:read_coils).with(100, 10).and_return([1, 0] * 5)
|
97
|
+
cmd.run %w(read 1.2.3.4 101 10)
|
98
|
+
stdout.should match(/^\s*106\s*0$/)
|
99
|
+
end
|
100
|
+
|
101
|
+
it 'accepts Modicon addresses for registers' do
|
102
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
103
|
+
slave.should_receive(:read_holding_registers).with(100, 10).and_return((0..9).to_a)
|
104
|
+
cmd.run %w(read 1.2.3.4 400101 10)
|
105
|
+
stdout.should match(/^\s*400106\s*5$/)
|
106
|
+
end
|
107
|
+
|
108
|
+
|
109
|
+
it 'should accept the --modicon option to force modicon output' do
|
110
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
111
|
+
slave.should_receive(:read_holding_registers).with(100, 10).and_return((0..9).to_a)
|
112
|
+
cmd.run %w(read --modicon 1.2.3.4 %MW100 10)
|
113
|
+
stdout.should match(/^\s*400106\s*5$/)
|
114
|
+
end
|
115
|
+
|
116
|
+
it 'should accept the --schneider option to force schneider output' do
|
117
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
118
|
+
slave.should_receive(:read_holding_registers).with(100, 10).and_return((0..9).to_a)
|
119
|
+
cmd.run %w(read --schneider 1.2.3.4 400101 10)
|
120
|
+
stdout.should match(/^\s*%MW105\s*5$/)
|
121
|
+
end
|
122
|
+
|
123
|
+
it 'has a --slave parameter' do
|
124
|
+
client = mock 'client'
|
125
|
+
ModBus::TCPClient.should_receive(:connect).with('X').and_yield(client)
|
126
|
+
client.should_receive(:with_slave).with(99)
|
127
|
+
cmd.run %w(read --slave 99 X 101 1)
|
128
|
+
end
|
129
|
+
|
130
|
+
it 'can write the output from reading registers to a yaml file using the -o <filename> parameter' do
|
131
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
132
|
+
slave.should_receive(:read_holding_registers).with(100, 1).and_return([1])
|
133
|
+
file_mock = mock('file')
|
134
|
+
File.should_receive(:open).and_yield(file_mock)
|
135
|
+
file_mock.should_receive(:puts).with({:host => '1.2.3.4', :slave => 1, :offset => '400101', :data => [1]}.to_yaml)
|
136
|
+
cmd.run %w(read --output filename.yml 1.2.3.4 %MW100 1)
|
137
|
+
stdout.should_not match(/./)
|
138
|
+
end
|
139
|
+
|
140
|
+
it 'can write the output from reading coils to a yaml file using the -o <filename> parameter' do
|
141
|
+
client, slave = standard_connect_helper '1.2.3.4'
|
142
|
+
slave.should_receive(:read_coils).with(100, 1).and_return([1])
|
143
|
+
file_mock = mock('file')
|
144
|
+
File.should_receive(:open).and_yield(file_mock)
|
145
|
+
file_mock.should_receive(:puts).with({:host => '1.2.3.4', :slave => 1, :offset => '101', :data => [1]}.to_yaml)
|
146
|
+
cmd.run %w(read --output filename.yml 1.2.3.4 %M100 1)
|
147
|
+
stdout.should_not match(/./)
|
148
|
+
end
|
149
|
+
|
150
|
+
|
151
|
+
end
|
152
|
+
|
153
|
+
|
154
|
+
|
155
|
+
|
156
|
+
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'rspec'
|
2
|
+
require 'clamp'
|
3
|
+
require 'stringio'
|
4
|
+
require 'modbus-cli'
|
5
|
+
|
6
|
+
# Borrowed from Clamp tests
|
7
|
+
module OutputCapture
|
8
|
+
|
9
|
+
def self.included(target)
|
10
|
+
target.before do
|
11
|
+
$stdout = @out = StringIO.new
|
12
|
+
$stderr = @err = StringIO.new
|
13
|
+
end
|
14
|
+
target.after do
|
15
|
+
$stdout = STDOUT
|
16
|
+
$stderr = STDERR
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def stdout
|
21
|
+
@out.string
|
22
|
+
end
|
23
|
+
|
24
|
+
def stderr
|
25
|
+
@err.string
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
def stub_tcpip
|
33
|
+
TCPSocket.stub!(:new) # prevent comms with actual PLC or device
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
def standard_connect_helper(address)
|
38
|
+
client = mock 'client'
|
39
|
+
slave = mock 'slave'
|
40
|
+
ModBus::TCPClient.should_receive(:connect).with(address).and_yield(client)
|
41
|
+
client.should_receive(:with_slave).with(1).and_yield(slave)
|
42
|
+
return client, slave
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
def cmd
|
47
|
+
Modbus::Cli::CommandLineRunner.new('modbus-cli')
|
48
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
|
4
|
+
|
5
|
+
describe Modbus::Cli::WriteCommand do
|
6
|
+
include OutputCapture
|
7
|
+
|
8
|
+
before(:each) do
|
9
|
+
stub_tcpip
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'can write to registers' do
|
13
|
+
client, slave = standard_connect_helper 'HOST'
|
14
|
+
slave.should_receive(:write_holding_registers).with(100, [1, 2, 3, 4])
|
15
|
+
cmd.run %w(write HOST %MW100 1 2 3 4)
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
it 'can write floating point numbers' do
|
20
|
+
client, slave = standard_connect_helper 'HOST'
|
21
|
+
slave.should_receive(:write_holding_registers).with(100, [52429, 17095, 52429, 17095])
|
22
|
+
cmd.run %w(write HOST %MF100 99.9 99.9)
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'can write double word numbers' do
|
26
|
+
client, slave = standard_connect_helper 'HOST'
|
27
|
+
slave.should_receive(:write_holding_registers).with(100, [16959, 15, 16959, 15])
|
28
|
+
cmd.run %w(write HOST %MD100 999999 999999)
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'can write to coils' do
|
32
|
+
client, slave = standard_connect_helper 'HOST'
|
33
|
+
slave.should_receive(:write_multiple_coils).with(100, [1, 0, 1, 0, 1, 0, 0, 1, 1])
|
34
|
+
cmd.run %w(write HOST %M100 1 0 1 0 1 0 0 1 1)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'rejects illegal values' do
|
38
|
+
lambda { cmd.run %w(write 1.2.3.4 %MW100 10 tust) }.should raise_exception(Clamp::UsageError)
|
39
|
+
lambda { cmd.run %w(write 1.2.3.4 %MW100 9999999) }.should raise_exception(Clamp::UsageError)
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'rejects illegal addresses' do
|
43
|
+
lambda { cmd.run %w(write 1.2.3.4 %MW1+00 ) }.should raise_exception(Clamp::UsageError)
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
it 'should split large writes in chunks for words' do
|
48
|
+
client, slave = standard_connect_helper 'HOST'
|
49
|
+
slave.should_receive(:write_holding_registers).with(100, (1..123).to_a)
|
50
|
+
slave.should_receive(:write_holding_registers).with(223, (124..150).to_a)
|
51
|
+
cmd.run %w(write HOST %MW100) + (1..150).to_a
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'should split large writes in chunks for coils' do
|
55
|
+
client, slave = standard_connect_helper 'HOST'
|
56
|
+
slave.should_receive(:write_multiple_coils).with(100, [0, 1] * 984)
|
57
|
+
slave.should_receive(:write_multiple_coils).with(2068, [0, 1] * 16)
|
58
|
+
cmd.run %w(write HOST %M100) + [0, 1] * 1000
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'can write to registers as ints' do
|
62
|
+
client, slave = standard_connect_helper 'HOST'
|
63
|
+
slave.should_receive(:write_holding_registers).with(100, [0xffff])
|
64
|
+
cmd.run %w(write --int HOST %MW100 -1)
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'can write to registers as floats' do
|
68
|
+
client, slave = standard_connect_helper 'HOST'
|
69
|
+
slave.should_receive(:write_holding_registers).with(100, [52429, 17095])
|
70
|
+
cmd.run %w(write --float HOST %MW100 99.9)
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'can write to registers as double words' do
|
74
|
+
client, slave = standard_connect_helper 'HOST'
|
75
|
+
slave.should_receive(:write_holding_registers).with(100, [16959, 15])
|
76
|
+
cmd.run %w(write --dword HOST %MW100 999999)
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'can write to registers as words' do
|
80
|
+
client, slave = standard_connect_helper 'HOST'
|
81
|
+
slave.should_receive(:write_holding_registers).with(100, [99])
|
82
|
+
cmd.run %w(write --word HOST %MF100 99)
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'can write to registers using Modicon addressing' do
|
86
|
+
client, slave = standard_connect_helper 'HOST'
|
87
|
+
slave.should_receive(:write_holding_registers).with(100, [1, 2, 3, 4])
|
88
|
+
cmd.run %w(write HOST 400101 1 2 3 4)
|
89
|
+
end
|
90
|
+
|
91
|
+
it 'can write to coils using Modicon addressing' do
|
92
|
+
client, slave = standard_connect_helper 'HOST'
|
93
|
+
slave.should_receive(:write_multiple_coils).with(100, [1, 0, 1, 0, 1, 0, 0, 1, 1])
|
94
|
+
cmd.run %w(write HOST 101 1 0 1 0 1 0 0 1 1)
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'has a --slave parameter' do
|
98
|
+
client = mock 'client'
|
99
|
+
ModBus::TCPClient.should_receive(:connect).with('X').and_yield(client)
|
100
|
+
client.should_receive(:with_slave).with(99)
|
101
|
+
cmd.run %w(write --slave 99 X 101 1)
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
105
|
+
|
106
|
+
|
107
|
+
|
108
|
+
|
109
|
+
|
metadata
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: modbus-cli
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Tallak Tveide
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2011-11-30 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rmodbus
|
16
|
+
requirement: &76170020 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - =
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 1.1.0
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *76170020
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: clamp
|
27
|
+
requirement: &76169740 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - =
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 0.3.0
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *76169740
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: rspec
|
38
|
+
requirement: &76169500 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - =
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: 2.7.0
|
44
|
+
type: :development
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *76169500
|
47
|
+
description: Command line interface to communicate over Modbus TCP
|
48
|
+
email:
|
49
|
+
- tallak@tveide.net
|
50
|
+
executables:
|
51
|
+
- modbus
|
52
|
+
extensions: []
|
53
|
+
extra_rdoc_files: []
|
54
|
+
files:
|
55
|
+
- .gitignore
|
56
|
+
- Gemfile
|
57
|
+
- README.markdown
|
58
|
+
- Rakefile
|
59
|
+
- bin/modbus
|
60
|
+
- lib/modbus-cli.rb
|
61
|
+
- lib/modbus-cli/commands_common.rb
|
62
|
+
- lib/modbus-cli/dump_command.rb
|
63
|
+
- lib/modbus-cli/read_command.rb
|
64
|
+
- lib/modbus-cli/version.rb
|
65
|
+
- lib/modbus-cli/write_command.rb
|
66
|
+
- modbus-cli.gemspec
|
67
|
+
- spec/dump_command_spec.rb
|
68
|
+
- spec/modbus_cli_spec.rb
|
69
|
+
- spec/read_command_spec.rb
|
70
|
+
- spec/spec_helper.rb
|
71
|
+
- spec/write_command_spec.rb
|
72
|
+
homepage: http://www.github.com/tallakt/modbus-cli
|
73
|
+
licenses: []
|
74
|
+
post_install_message:
|
75
|
+
rdoc_options: []
|
76
|
+
require_paths:
|
77
|
+
- lib
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
79
|
+
none: false
|
80
|
+
requirements:
|
81
|
+
- - ! '>='
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '0'
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
85
|
+
none: false
|
86
|
+
requirements:
|
87
|
+
- - ! '>='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
requirements: []
|
91
|
+
rubyforge_project: modbus-cli
|
92
|
+
rubygems_version: 1.8.10
|
93
|
+
signing_key:
|
94
|
+
specification_version: 3
|
95
|
+
summary: Modbus command line
|
96
|
+
test_files: []
|
97
|
+
has_rdoc:
|