-
-
Notifications
You must be signed in to change notification settings - Fork 114
/
Copy pathREADME.txt
3096 lines (2560 loc) · 135 KB
/
README.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
Perry Kundert
Table of Contents
─────────────────
1. Comm. Protocol Python Parser and Originator
.. 1. Installing
..... 1. Installing from source
..... 2. Python Version and OS Support
2. Protocols
.. 1. EtherNet/IP CIP Controller Communications Simulator/Client
..... 1. EtherNet/IP Controller Communications Simulator
..... 2. EtherNet/IP Controller Object Configuration
..... 3. Routing via `route_path' to other CIP Devices
..... 4. EtherNet/IP Controller I/O Customization
..... 5. EtherNet/IP Controller Client
..... 6. EtherNet/IP `cpppo.server.enip.client' API
..... 7. EtherNet/IP `cpppo.server.enip.get_attribute' API
..... 8. EtherNet/IP `cpppo.server.enip.poll' API
..... 9. Web Interface
3. Remote PLC I/O
.. 1. Modbus/TCP Simulator and Client
..... 1. `cpppo.remote.plc_modbus.poller_modbus' API
..... 2. `cpppo.remote.pymodbus_fixes'
4. Deterministic Finite Automata
.. 1. Basic State Machines
.. 2. Composite Machines
.. 3. Machines from Regular Expressions
..... 1. Consume all possible symbols: `greedy'
..... 2. Detect if regular expression satisfied: `terminal'
..... 3. Unicode Support
5. Running State Machines
6. Historical
.. 1. The `timestamp'
7. Virtualization
.. 1. Vagrant
..... 1. VMware Fusion 7
..... 2. Vagrant Failure due to VMware Networking Problems
..... 3. Vagrant's VMware Fusion/Workstation Provider Plugin
..... 4. Building a Vagrant Image
.. 2. Docker
..... 1. Creating Docker images from a Dockerfile
1 Comm. Protocol Python Parser and Originator
═════════════════════════════════════════════
Cpppo (pronounced 'c'+3*'p'+'o' in Python) is used to implement binary
communications protocol parsers. The protocol's communication
elements are described in terms of state machines which change state
in response to input events, collecting the data and producing output
data artifacts.
1.1 Installing
──────────────
Cpppo depends on several Python packages:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Package For? Description
──────────────────────────────────────────────────────────────────────────────────────────────
greenery>=2.0,<3.0 all Regular Expression parsing and state machinery library
ipaddress all IP address manipulation
argparse all (<2.7) Command-line argument parsing
configparser all (<3.0) Parsing for CIP Object configuration files
pytz>2014.7 history The Python time-zone library
tzlocal>=1.1.1 history Access to system's local timezone (on Mac, Windows)
pymodbus>=1.2.0 remote Modbus/TCP support for polling Schneider compatible PLCs
pytest all tests A Python unit-test framework
web.py>=0.37 web API (<3.0) The web.py HTTP web application framework (optional)
minimalmodbus serial tests A Modbus implementation, used for testing Modbus serial
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
To install 'cpppo' and its required dependencies using pip
(recommended):
┌────
│ $ pip install cpppo
└────
1.1.1 Installing from source
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Clone the repo by going to your preferred source directory and using:
┌────
│ $ git clone [email protected]:pjkundert/cpppo.git
└────
You can then install from the provided setuptools-based setup.py
installer:
┌────
│ $ cd cpppo
│ $ python setup.py install
└────
If you do not install using `pip install cpppo' or `python setup.py
install' (recommended), you will need to install these dependencies
manually. To install all required and optional Python modules, use:
┌────
│ pip install -r requirements.txt
│ pip install -r requirements-optional.txt
└────
For Python2, you will also need to `pip install configparser'
manually.
1.1.2 Python Version and OS Support
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Cpppo is implemented and fully tested on both Python 2 (2.6 and 2.7),
and Python 3 (3.3 to 3.5). The EtherNet/IP CIP protocol
implementation is fully tested and widely used in both Python 2 and 3.
Some of cpppo's modules are not (yet) fully supported in both
versions:
• The pymodbus module does not support Python 3, so Modbus/TCP support
for polling remote PLCs is only available for Python 2.
• Greenery supports both Python 2 and 3, but doesn't provide
meaningful Unicode (UTF-8) support in Python 2, so regular
expression based DFAs dealing in UTF-8 are only supported for Python
3.
Linux (native or Docker containerized), Mac and Windows OSs are
supported. However, Linux or Mac are recommended for stability,
performance and ease of use. If you need to use Windows, it is
recommended that you install a usable Terminal application such as
[ConEmu].
[ConEmu] <https://github.com/Maximus5/ConEmu>
2 Protocols
═══════════
The protocols implemented are described here.
2.1 EtherNet/IP CIP Controller Communications Simulator/Client
──────────────────────────────────────────────────────────────
A subset of the EtherNet/IP client and server protocol is implemented,
and a simulation of a subset of the Tag communications capability of a
Allen-Bradley ControlLogix 5561 Controller is provided. It is capable
of simulating ControlLogix Tag access, via the Read/Write Tag
[Fragmented] services.
Only EtherNet/IP "Unconnected" type connections are supported. These
are (somewhat anomalously) a persistent TCP/IP connection from a
client to a single EtherNet/IP device (such as a *Logix Controller),
which allow the client to issue a sequence of CIP service requests
(commands) to be sent to arbitrary CIP objects resident on the target
device. Cpppo does not implement "Connected" requests (eg. those
typically used between *Logix PLCs, in an industrial LAN environment).
A Tag is simply a shortcut to a specific EtherNet/IP CIP Object
Instance and Attribute. Instead of the Client needing to know the
specific Instance and Attribute numbers, the more easily remembered
and meaningful Tag may be supplied in the request path.
2.1.1 EtherNet/IP Controller Communications Simulator
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
To run a simulation of a subset of a ControlLogix(tm) Controller
communications, with the array Tags 'SCADA' and 'TEXT' and scalar Tag
'FLOAT' for you to read/write, run `python -m cpppo.server.enip' or
`enip_server':
┌────
│ enip_server --print SCADA=INT[1000] TEXT=SSTRING[100] FLOAT=REAL
└────
Each Tag references a specific CIP Class/Instance/Attribute, which can
be specified, if you desire (eg. to use numeric CIP addressing,
typically required for Get/Set Attribute Single requests):
┌────
│ enip_server --print SCADA@22/1/1=INT[1000] TEXT@22/1/2=SSTRING[100] FLOAT@22/1/3=REAL
└────
(See `cpppo/server/enip/poll_test.py''s `main' method (at the end of
the file) for an example of how to implement a completely custom set
of CIP Objects and Attributes, to simulate some aspects of some
specific device (in this case, an Allen-Bradley PowerFlex 750).
The following options are available when you execute the
cpppo.server.enip module:
Specify a different local interface and/or port to bind to (default is
`:44818', indicating all interfaces and port 44818):
┌────
│ -a|--address [<interface>][:<port>]
└────
Change the verbosity (supply more to increase further):
┌────
│ -v[vv...]|--verbose
└────
Specify a constant or variable delay to apply to every response, in
fractional seconds:
┌────
│ -d|--delay #.#[-#.#]
└────
Specify an HTTP web server interface and/or port, if a web API is
desired (just ':' will enable the web API on defaults :80, or whatever
interface was specified for –address):
┌────
│ -w|--web [<interface>]:[<port>]
└────
To send log output to a file (limited to 10MB, rotates through 5
copies):
┌────
│ -l|--log <file>
└────
To print a summary of PLC I/O to stdout:
┌────
│ -p|--print
│ --no-print (the default)
└────
To specify and check for a specific `route_path' in incoming
Unconnected Send requests, provide one in "<port>/<link>" or JSON
format; the default is to ignore the specified `route_path' (accepting
any `route_path'). If specified, it must be a list containing one
dict, specifying a `port' and `link' value. The `port' is either an
8- or 16-bit number (eg. port 1 typically indicates the local
backplane). The `link' is typically in the range 0-15 (eg. a "slot"
number), or is an IP address (eg. "1.2.3.4"). To specify that no
`route_path' is accepted (ie. only an empty `route_path' is allowed,
ie. a Simple request), use 0 or false:
┌────
│ --route-path '[{"port": 1, "link": 0]' # backplane, slot 0
│ --route-path 1/0 # ''
│ --route-path '[{"port": 2, "link": "192.168.1.2"}]' # { port 2, link 192.168.1.2 }
│ --route-path 2/192.168.1.2 # ''
│ --route-path 1/0/2/192.168.1.2 # { backplane, slot 0 }, { port 2, link 192.168.1.2 }
│ --route-path false # No route_path accepted
└────
Note that incoming "Simple" requests to a full-featured "Routing"
simulator configured with a route path *will be accepted*; the
specified target CIP Object(s) must exist in the target simulator.
Alternatively, to easily specify acceptance of no routing Unconnected
Send encapsulation (eg. to simulate simple non-routing CIP devices
such as Rockwell MicroLogix or A-B PowerFlex):
┌────
│ -S|--simple
└────
You may specify as many tags as you like on the command line; at least
one is required:
┌────
│ <tag>=<type>[<length>] # eg. SCADA=INT[1000]
└────
You may specifiy a CIP Class, Instance and Attribute number for the
Tag to be associated with:
┌────
│ Motor_Velocity@0x93/3/10=REAL
└────
The available types are SINT (8-bit), INT (16-bit), DINT (32-bit)
integer, and REAL (32-bit float). BOOL (8-bit, bit #0), SSTRING and
STRING are also supported.
2.1.2 EtherNet/IP Controller Object Configuration
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
To replace the default values contained by default in the standard CIP
Objects (eg. the CIP Identity, TCP/IP Objects), place a `cpppo.cfg'
file in `/etc' or (on Windows) `%APPDATA%', or a `.cpppo.cfg' in your
home directory, or a `cpppo.cfg' file in the current working directory
where your application is run.
For example, to change the simulated EtherNet/IP CIP Identity Object
'Product Name' (the SSTRING at Class 0x01, Instance 1, Attribute 7),
and the CIP TCP/IP Object Interface Configuration and Host Name,
create a `cpppo.cfg' file containing:
┌────
│ [Identity]
│ # Generally, strings are not quoted
│ Product Name = 1756-L61/B LOGIX5561
│
│ [TCPIP]
│ # However, some complex structures require JSON configuration:
│ Interface Configuration = {
│ "ip_address": "192.168.0.201",
│ "network_mask": "255.255.255.0",
│ "dns_primary": "8.8.8.8",
│ "dns_secondary": "8.8.4.4",
│ "domain_name": "example.com"
│ }
│ Host Name = controller
└────
See <https://github.com/pjkundert/cpppo/blob/master/cpppo.cfg> for
details on the file format
(<https://docs.python.org/3/library/configparser.html>).
Place this file in one of the above-mentioned locations, and run:
┌────
│ $ python -m cpppo.server.enip -v
│ 01-20 07:01:29.125 ... NORMAL main Loaded config files: ['cpppo.cfg']
│ ...
└────
Use the new EtherNet/IP CIP `cpppo.server.enip.poll' API to poll the
Identity and TCPIP Objects and see the results:
┌────
│ $ python3 -m cpppo.server.enip.poll -v TCPIP Identity
│ 01-20 07:04:46.253 ... NORMAL run Polling begins \
│ via: 1756-L61/C LOGIX5561 via localhost:44818[850764823]
│ TCPIP: [2, 48, 0, [{'class': 246}, {'instance': 1}], '192.168.0.201', \
│ '255.255.255.0', '0.0.0.0', '8.8.8.8', '8.8.4.4', 'example.com', 'controller']
│ Identity: [1, 15, 54, 2836, 12640, 7079450, '1756-L61/C LOGIX5561', 255]
└────
2.1.3 Routing via `route_path' to other CIP Devices
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
A very basic facility for routing incoming CIP requests with complex
`route_path' values is available in the Cpppo EtherNet/IP CIP
Communications Simulator. By default, the Simulator responds to
incoming requests with *any* route_path (basically, it ignores the
value).
If you specify `--route-path=1/0' on the command-line, it will only
respond to requests with exactly the `route_path' equal to '{"port":
1, "link": 0}' (backplane, slot 0). Every other CIP reqeuest with
some other `route_path' value will be responded to with an error
status.
If you wish to configure the allowable `route_path' in the `cpppo.cfg'
file, use "Route Path = …". Furthermore, if you want to route any
other valid CIP request by the first element in its `route_path',
specify a JSON mapping in the configuration file's "Route = { …",
specifying each `route_path' by <port>/<link> (link ranges are
handled), and the <IP>:<port> it should be routed to:
┌────
│ [UCMM]
│ Route Path = 1/0
│ Route = {
│ "1/1-15": "localhost:44819"
│ }
└────
This example (see `cpppo/cpppo-router.cfg' and `cpppo/cpppo.cfg' for
more details) accepts and handles CIP requests to `route_path' port 1,
link 0 (backplane slot 0), and routes requests to all other backplane
slots to the EtherNet/IP CIP simulator on localhost port 44819. Any
valid `route_path' is allowed; for example, "2/1.2.3.4" would route
requests with a `route_path' segment specifying port 2, link
"1.2.3.4".
When the request is forwarded, the first `route_path' segment is
removed, and the remaining segments (if any) are forwarded. If no
more `route_path' segments are left, then the request is forwarded as
a "Simple" CIP request (with no `route_path' or `send_path'
configured, as for a simple non-routing CIP device such as a
MicroLogix or A-B Powerflex, etc.)
2.1.4 EtherNet/IP Controller I/O Customization
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
If you require access to the read and write I/O events streaming from
client(s) to and from the EtherNet/IP CIP Attributes hosted in your
simulated controller, you can easily make a custom
cpppo.server.enip.device Attribute implementation which will receive
all PLC Read/Write Tag [Fragmented] request data.
We provide two examples; one which records a history of all read/write
events to each Tag, and one which connects each Tag to the current
temperature of the city with the same name as the Tag.
◊ 2.1.4.1 Record Tag History
For example purposes, we have implemented the
cpppo.server.enip.historize module which simulates an EtherNet/IP CIP
device, intercepts all I/O (and exceptions) and writes it to the file
specified in the *first* command-line argument to the module. It uses
`cpppo.history.timestamp', and requires that the Python `pytz' module
be installed (via `pip install pytz'), which also requires that a
system timezone be set.
This example *captures the first command line argument* as a file
name; all subsequent arguments are the same as described for the
EtherNet/IP Controller Communications Simulator, above:
┌────
│ $ python -m cpppo.server.enip.historize some_file.hst Tag_Name=INT[1000] &
│ $ tail -f some_file.txt
│ # 2014-07-15 22:03:35.945: Started recording Tag: Tag_Name
│ 2014-07-15 22:03:44.186 ["Tag_Name", [0, 3]] {"write": [0, 1, 2, 3]}
│ ...
└────
(in another terminal)
┌────
│ $ python -m cpppo.server.enip.client Tag_Name[0-3]=[0,1,2,3]
└────
You can examine the code in `cpppo/server/enip/historize.py' to see
how to easily implement your own customization of the EtherNet/IP CIP
Controller simulator.
If you invoke the 'main' method provided by cpppo.server.enip.main
directly, all command-line args will be parsed, and the EtherNet/IP
service will not return control until termination. Alternatively, you
may start the service in a separate threading.Thread and provide it
with a list of configuration options. Note that each individual
EtherNet/IP Client session is serviced by a separate Thread, and thus
all method invocations arriving at your customized Attribute object
need to process data in a Thread-safe fashion.
◊ 2.1.4.2 City Temperature Tag
In this example, we intercept read requests to the Tag, and look up
the current temperature of the city named with the Tag's name. This
example is simple enough to include here (see
`cpppo/server/enip/weather.py'):
┌────
│ import sys, logging, json
│ try: # Python2
│ from urllib2 import urlopen
│ from urllib import urlencode
│ except ImportError: # Python3
│ from urllib.request import urlopen
│ from urllib.parse import urlencode
│
│ from cpppo.server.enip import device, REAL
│ from cpppo.server.enip.main import main as enip_main
│
│ class Attribute_weather( device.Attribute ):
│ OPT = {
│ "appid": "078b5bd46e99c890482fc1252e9208d5",
│ "units": "metric",
│ "mode": "json",
│ }
│ URI = "http://api.openweathermap.org/data/2.5/weather"
│
│ def url( self, **kwds ):
│ """Produce a url by joining the class' URI and OPTs with any keyword parameters"""
│ return self.URI + "?" + urlencode( dict( self.OPT, **kwds ))
│
│ def __getitem__( self, key ):
│ """Obtain the temperature of the city's matching our Attribute's name, convert
│ it to an appropriate type; return a value appropriate to the request."""
│ try:
│ # eg. "http://api.openweathermap.org/...?...&q=City Name"
│ data = urlopen( self.url( q=self.name )).read()
│ if type( data ) is not str: # Python3 urlopen.read returns bytes
│ data = data.decode( 'utf-8' )
│ weather = json.loads( data )
│ assert weather.get( 'cod' ) == 200 and 'main' in weather, \
│ weather.get( 'message', "Unknown error obtaining weather data" )
│ cast = float if isinstance( self.parser, REAL ) else int
│ temperature = cast( weather['main']['temp'] )
│ except Exception as exc:
│ logging.warning( "Couldn't get temperature for %s via %r: %s",
│ self.name, self.url( q=self.name ), exc )
│ raise
│ return [ temperature ] if self._validate_key( key ) is slice else temperature
│
│ def __setitem__( self, key, value ):
│ raise Exception( "Changing the weather isn't that easy..." )
│
│ sys.exit( enip_main( attribute_class=Attribute_weather ))
└────
By providing a specialized implementation of device.Attribute's
`__getitem__' (which is invoked each time an Attribute is accessed),
we arrange to query the city's weather at the given URL, and return
the current temperature. The data must be converted to a Python type
compatible with the eventual CIP type (ie. a float, if the CIP type is
REAL). Finally, it must be returned as a sequence if the
`__getitem__' was asked for a Python `slice'; otherwise, a single
indexed element is returned.
Of course, `__setitem__' (which would be invoked whenever someone
wishes to change the city's temperature) would have a much more
complex implementation, the details of which are left as an exercise
to the reader…
2.1.5 EtherNet/IP Controller Client
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Cpppo provides an advanced EtherNet/IP CIP Client `enip_client', for
processing "Unconnected" (or "Explicit") requests via TPC/IP or UDP/IP
sessions to CIP devices – either Controllers (eg. Rockwell
ControlLogix, CompactLogix) which can "route" CIP requests, or w/ the
`-S' option for access to simple CIP devices (eg. Rockwell MicroLogix,
A-B PowerFlex, …) which do not understand the "routing" CIP
Unconnected Send encapsulation required by the more advanced "routing"
Controllers.
Cpppo does not presently implement the CIP "Forward Open" request, nor
the resulting "Connected" or "Implicit" I/O requests, typically used
in direct PLC-to-PLC communications. Only the TCP/IP
"Unconnected"/"Explicit" requests that pass over the initially created
and CIP Registered session are implemented.
The `python -m cpppo.server.enip.client' module entry-point or API (or
the `enip_client' command ) can Register and issue a stream of
"Unconnected" requests to the Controller, such as Get/Set Attribute or
(by default) *Logix Read/Write Tag (optionally Fragmented) requests.
The `cpppo.server.enip.get_attribute' module entry-point or API and
the `enip_get_attribute' command defaults to use Get/Set Attribute
operations.
It is critical to use the correct API with the correct address type
and options, to achieve communications with your device. Some devices
can use "Unconnected" requests, while others cannot. The MicroLogix
is such an example; you may use "Unconnected" requests to access basic
CIP Objects (such as Identity), but not much else. Most other devices
can support "Unconnected" access to their data. Some devices can only
perform basic CIP services such as "Get/Set Attribute Single/All"
using numeric CIP Class, Instance and Attribute addressing, while
others support the *Logix "Read/Write Tag [Fragmented]" requests using
Tag names. You need to know (or experiment) to discover their
capability. Still others such as the CompactLogix and ControlLogix
Controllers can support "routing" requests; many others require the
`-S' option to disable this functionality, or they will respond with
an error status.
To issue Read/Write Tag [Fragmented] requests, by default to a
"routing" device (eg. ControlLogix, CompactLogix), here to a CIP `INT'
array Tag called `SCADA', and a CIP `SSTRING' (Short String) array Tag
called `TEXT':
┌────
│ $ python -m cpppo.server.enip.client -v --print \
│ SCADA[1]=99 SCADA[0-10] 'TEXT[1]=(SSTRING)"Hello, world!"' TEXT[0-3]
└────
To use only Get Attribute Single/All requests (suitable for simpler
devices, usually also used with the `-S' option, for no routing path),
use this API instead (use the `--help' option to see their options,
which are quite similar to `cpppo.server.enip.client' and
`enip_client'):
┌────
│ $ python -m cpppo.server.enip.get_attribute -S ...
└────
All data is read/written as arrays of `SINT'; however, if you specify
a data type for writing data, we will convert it to an array of `SINT'
for you. For example, if you know that you are writing to a `REAL'
Attribute:
┌────
│ $ python -m cpppo.server.enip -v 'Motor_Velocity@0x93/3/10=REAL' # In another terminal...
│ $ python -m cpppo.server.enip.get_attribute '@0x93/3/10=(REAL)1.0' '@0x93/3/10'
│ Sat Feb 20 08:24:13 2016: 0: Single S_A_S @0x0093/3/10 == True
│ Sat Feb 20 08:24:13 2016: 1: Single G_A_S @0x0093/3/10 == [0, 0, 128, 63]
│ $ python -m cpppo.server.enip.client --print Motor_Velocity
│ Motor_Velocity == [1.0]: 'OK'
└────
To access Get Attribute data with CIP type conversion, use
`cpppo.server.enip.get_attribute''s `proxy' classes, instead.
Specify a different local interface and/or port to connect to (default
is :44818):
┌────
│ -a|--address [<interface>][:<port>]
└────
On Windows systems, you must specify an actual interface. For
example, if you started the cpppo.server.enip simulator above (running
on the all interfaces by default), use `--address localhost'.
Select the UDP/IP network protocol and optional "broadcast" support.
Generally, EtherNet/IP CIP devices support UDP/IP only for some basic
requests such as List Services, List Identity and List Interfaces:
┌────
│ -u|--udp
│ -b|--broadcast
└────
Send List Identity/Services/Interfaces requests:
┌────
│ -i|--list-identity
│ -s|--list-services
│ -I|--list-interfaces
└────
For example, to find the Identity of all of the EtherNet/IP CIP
devices on a local LAN with broadcast address 192.168.1.255 (that
respond to broadcast List Identity via UDP/IP):
┌────
│ $ python -m cpppo.server.enip.client --udp --broadcast --list-identity -a 192.168.1.255
│ List Identity 0 from ('192.168.1.5', 44818): {
│ "count": 1,
│ "item[0].length": 58,
│ "item[0].identity_object.sin_addr": "192.168.1.5",
│ "item[0].identity_object.status_word": 48,
│ "item[0].identity_object.vendor_id": 1,
│ "item[0].identity_object.product_name": "1769-L18ER/A LOGIX5318ER",
│ "item[0].identity_object.sin_port": 44818,
│ "item[0].identity_object.state": 3,
│ "item[0].identity_object.version": 1,
│ "item[0].identity_object.device_type": 14,
│ "item[0].identity_object.sin_family": 2,
│ "item[0].identity_object.serial_number": 1615052645,
│ "item[0].identity_object.product_code": 154,
│ "item[0].identity_object.product_revision": 2837,
│ "item[0].type_id": 12
│ }
│ List Identity 1 from ('192.168.1.4', 44818): {
│ "count": 1,
│ "item[0].length": 63,
│ "item[0].identity_object.sin_addr": "192.168.1.4",
│ "item[0].identity_object.status_word": 48,
│ "item[0].identity_object.vendor_id": 1,
│ "item[0].identity_object.product_name": "1769-L23E-QBFC1 Ethernet Port",
│ "item[0].identity_object.sin_port": 44818,
│ "item[0].identity_object.state": 3,
│ "item[0].identity_object.version": 1,
│ "item[0].identity_object.device_type": 12,
│ "item[0].identity_object.sin_family": 2,
│ "item[0].identity_object.serial_number": 3223288659,
│ "item[0].identity_object.product_code": 191,
│ "item[0].identity_object.product_revision": 3092,
│ "item[0].type_id": 12
│ }
│ List Identity 2 from ('192.168.1.3', 44818): {
│ "count": 1,
│ "item[0].length": 53,
│ "item[0].identity_object.sin_addr": "192.168.1.3",
│ "item[0].identity_object.status_word": 4,
│ "item[0].identity_object.vendor_id": 1,
│ "item[0].identity_object.product_name": "1766-L32BXBA A/7.00",
│ "item[0].identity_object.sin_port": 44818,
│ "item[0].identity_object.state": 0,
│ "item[0].identity_object.version": 1,
│ "item[0].identity_object.device_type": 14,
│ "item[0].identity_object.sin_family": 2,
│ "item[0].identity_object.serial_number": 1078923367,
│ "item[0].identity_object.product_code": 90,
│ "item[0].identity_object.product_revision": 1793,
│ "item[0].type_id": 12
│ }
│ List Identity 3 from ('192.168.1.2', 44818): {
│ "count": 1,
│ "item[0].length": 52,
│ "item[0].identity_object.sin_addr": "192.168.1.2",
│ "item[0].identity_object.status_word": 4,
│ "item[0].identity_object.vendor_id": 1,
│ "item[0].identity_object.product_name": "1763-L16DWD B/7.00",
│ "item[0].identity_object.sin_port": 44818,
│ "item[0].identity_object.state": 0,
│ "item[0].identity_object.version": 1,
│ "item[0].identity_object.device_type": 12,
│ "item[0].identity_object.sin_family": 2,
│ "item[0].identity_object.serial_number": 1929488436,
│ "item[0].identity_object.product_code": 185,
│ "item[0].identity_object.product_revision": 1794,
│ "item[0].type_id": 12
│ }
└────
Sends certain "Legacy" EtherNet/IP CIP requests:
┌────
│ -L|--legacy <command>
└────
Presently, only the following Legacy commands are implemented:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Command Description
────────────────────────────────────────────────────────────────────────
0x0001 Returns some of the same network information as List Identity
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
This command is not documented, and is not implemented on all types of
devices
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
IP Device Product Name
─────────────────────────────────────────────────────────────
192.168.1.2 MicroLogix 1100 1763-L16DWD B/7.00
192.168.1.3 MicroLogix 1400 1766-L32BXBA A/7.00
192.168.1.4 CompactLogix 1769-L23E-QBFC1 Ethernet Port
192.168.1.5 CompactLogix 1769-L18ER/A LOGIX5318ER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
┌────
│ $ python -m cpppo.server.enip.client --udp --broadcast --legacy 0x0001 -a
│ 192.168.1.255
│ Legacy 0x0001 0 from ('192.168.1.3', 44818): {
│ "count": 1,
│ "item[0].legacy_CPF_0x0001.sin_addr": "192.168.1.3",
│ "item[0].legacy_CPF_0x0001.unknown_1": 0,
│ "item[0].legacy_CPF_0x0001.sin_port": 44818,
│ "item[0].legacy_CPF_0x0001.version": 1,
│ "item[0].legacy_CPF_0x0001.sin_family": 2,
│ "item[0].legacy_CPF_0x0001.ip_address": "192.168.1.3",
│ "item[0].length": 36,
│ "item[0].type_id": 1
│ }
│ Legacy 0x0001 1 from ('192.168.1.5', 44818): {
│ "peer": [
│ "192.168.1.5",
│ 44818
│ ],
│ "enip.status": 1,
│ "enip.sender_context.input": "array('c',
│ '\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00')",
│ "enip.session_handle": 0,
│ "enip.length": 0,
│ "enip.command": 1,
│ "enip.options": 0
│ }
│ Legacy 0x0001 2 from ('192.168.1.4', 44818): {
│ "count": 1,
│ "item[0].legacy_CPF_0x0001.sin_addr": "192.168.1.4",
│ "item[0].legacy_CPF_0x0001.unknown_1": 0,
│ "item[0].legacy_CPF_0x0001.sin_port": 44818,
│ "item[0].legacy_CPF_0x0001.version": 1,
│ "item[0].legacy_CPF_0x0001.sin_family": 2,
│ "item[0].legacy_CPF_0x0001.ip_address": "192.168.1.4",
│ "item[0].length": 36,
│ "item[0].type_id": 1
│ }
│ Legacy 0x0001 3 from ('192.168.1.2', 44818): {
│ "count": 1,
│ "item[0].legacy_CPF_0x0001.sin_addr": "192.168.1.2",
│ "item[0].legacy_CPF_0x0001.unknown_1": 0,
│ "item[0].legacy_CPF_0x0001.sin_port": 44818,
│ "item[0].legacy_CPF_0x0001.version": 1,
│ "item[0].legacy_CPF_0x0001.sin_family": 2,
│ "item[0].legacy_CPF_0x0001.ip_address": "192.168.1.2",
│ "item[0].length": 36,
│ "item[0].type_id": 1
│ }
└────
Change the verbosity (supply more to increase further):
┌────
│ -v[vv...]|--verbose
└────
Change the default response timeout
┌────
│ -t|--timeout #
└────
Specify a number of times to repeat the specified operations:
┌────
│ -r|--repeat #
└────
To specify an Unconnected Send `route_path' (other than the default
backplane port 0, '1/0' or '[{"port": 1, "link": 0}]', which is a
guess at the location of a *Logix controller in a typical backplane),
provide one in short <port>/<link> or JSON format. It must be a list
containing one dict specifying a `port' and `link' value. The `port'
is either an 8- or 16-bit number, and `link' is typically in the range
0-15 (a backplane slot) or an IP address. A string with a '/' in it
is parsed as <port>/<link>. If a only single `route_path' element is
intended, the JSON array notation is optional:
┌────
│ --route-path '[{"port": 1, "link": 0}]' # backplane, slot 0
│ --route-path '{"port": 1, "link": 0}' # ''
│ --route-path '1/0' # ''
└────
Complex multi-segment route-paths must be specified in a JSON list.
For example, to route via an EtherNet/IP module in backplane slot 3,
and then out its second Ethernet port to address 1.2.3.4:
┌────
│ --route-path '["1/3",{"port":2,"link":"1.2.3.4"}]'# backplane slot 3,
│ --route-path '["1/3","2/1.2.3.4"]' # then second port and IP 1.2.3.4
└────
To specify no `route_path', use 0 or false (usually only in concert
with –send-path='', or just use -S):
┌────
│ --route-path false
└────
If a simple EtherNet/IP CIP device doesn't support routing of message
to other CIP devices, and hence supports no Message Router Object, an
empty send-path may be supplied Normally, this also implies no
route-path, so is usually used in combination with
`--route-path=false'. This can be used to prevent the issuance of
Unconnected Send Service encapsulation, which "Only originating
devices and devices that route between links need to implement" (see
The CIP Networks Library, Vol 1, Table 3-5.8). Also avoid use of
`--multiple', as these devices do not generally accept Multiple
Service Packet requests, either.
Therefore, to communicate with simple, non-routing CIP devices (eg. AB
PowerFlex, …), use `-S' or `--simple', or explicitly:
┌────
│ --send-path='' --route-path=false
└────
Alternatively, to easily specify use of no routing Unconnected Send
encapsulation in requests:
┌────
│ -S|--simple
└────
Specify `timeout_ticks' (default: 157 * 32ms == 5s.)
┌────
│ --timeout-ticks 63 # ~2s (if ticks == 32ms)
└────
Specify the tick duration to use, when computing the actual timeout
from `timeout_ticks'. Each tick has a duration from 1 to 32,768
milliseconds, computed as `2 ** <priority_tick_time>' milliseconds.
┌────
│ --priority-time-tick 0 # Set 1ms. ticks
└────
To send log output to a file (limited to 10MB, rotates through 5
copies):
┌────
│ -l|--log <file>
└────
To print a summary of PLC I/O to stdout, use `--print'. Perhaps
surprisingly, unless you provide a `--print' or `-v' option, you will
see no output from the `python -m cpppo.server.enip.client' or
`enip_client' command, at all. The I/O operations will be performed,
however:
┌────
│ -p|--print
│ --no-print (the default)
└────
To force use of the Multiple Service Packet request, which carries
multiple Read/Write Tag [Fragmented] requests in a single EtherNet/IP
CIP I/O operation (default is to issue each request as a separate I/O
operation):
┌────
│ -m|--multiple
└────
To force the client to use plain Read/Write Tag commands (instead of
the Fragmented commands, which are the default):
┌────
│ -n|--no-fragment
└────
You may specify as many tags as you like on the command line; at least
one is required. An optional register (range) can be specified
(default is register 0):
┌────
│ <tag> <tag>[<reg>] <tag>[<reg>-<reg>] # eg. SCADA SCADA[1] SCADA[1-10]
└────
Writing is supported; the number of values must exactly match the data
specified register range:
┌────
│ <tag>=<value> # scalar, eg. SCADA=1
│ <tag>[<reg>-<reg>]=<value>,<value>,... # vector range
│ <tag>[<reg>]=<value> # single element of a vector
│ <tag>[<reg>-<reg>]=(DINT)<value>,<value> # cast to SINT/INT/DINT/REAL/BOOL/SSTRING/STRING
└────
By default, if any <value> contains a '.' (eg. '9.9,10'), all values
are deemed to be REAL; otherwise, they are integers and are assumed to
be type INT. To force a specific type (and limit the values to the
appropriate value range), you may specify a "cast" to a specific type,
eg. 'TAG[4-6]=(DINT)1,2,3'. The types SINT, INT, DINT, REAL, BOOL,
SSTRING and STRING are supported.
In addition to symbolic Tag addressing, numeric
Class/Instance/Attribute addressing is available. A Class, Instance
and Attribute address values are in decimal by default, but
hexadecimal, octal etc. are available using escapes, eg. 26 `= 0x1A ='
0o49 == 0b100110:
┌────
│ @<class>/<instance>/<attribute> # read a scalar, eg. @0x1FF/01/0x1A
│ @<class>/<instance>/<attribute>[99]=1 # write element, eg. @511/01/26=1
└────
See further details of addressing `cpppo.server.enip.client''s
`parse_operations' below.
2.1.6 EtherNet/IP `cpppo.server.enip.client' API
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Dispatching a multitude of EtherNet/IP CIP I/O operations to a
Controller (with our without pipelining) is very simple. If you don't
need to see the results of each operation as they occur, or just want
to ensure that they succeeded, you can use `connector.process' (see
`cpppo/server/enip/client/io_example.py'):
┌────
│ host = 'localhost' # Controller IP address
│ port = address[1] # default is port 44818
│ depth = 1 # Allow 1 transaction in-flight
│ multiple = 0 # Don't use Multiple Service Packet
│ fragment = False # Don't force Read/Write Tag Fragmented
│ timeout = 1.0 # Any PLC I/O fails if it takes > 1s
│ printing = True # Print a summary of I/O
│ tags = ["Tag[0-9]+16=(DINT)4,5,6,7,8,9", "@0x2/1/1", "Tag[3-5]"]
│
│ with client.connector( host=host, port=port, timeout=timeout ) as connection:
│ operations = client.parse_operations( tags )
│ failures,transactions = connection.process(
│ operations=operations, depth=depth, multiple=multiple,
│ fragment=fragment, printing=printing, timeout=timeout )
│
│ sys.exit( 1 if failures else 0 )
└────
Try it out by starting up a simulated Controller:
┌────
│ $ python -m cpppo.server.enip Tag=DINT[10] &
│ $ python -m cpppo.server.enip.io
└────
The API is able to "pipeline" requests – issue multiple requests on
the wire, while simultaneously harvesting the results of prior
requests. This is absolutely necessary in order to obtain reasonable
I/O performance over high-latency links (eg. via Satellite).
To use pipelining, create a `client.connector' which establishes and
registers a CIP connection to a Controller. Then, produce a sequence
of operations (eg, parsed from "Tag[0-9]+16=(DINT)5,6,7,8,9" or from
numeric Class, Instance and Attribute numbers "@2/1/1" ), and dispatch
the requests using connector methods `.pipeline' or `.synchronous' (to
access the details of the requests and the harvested replies), or
`.process' to simply get a summary of I/O failures and total
transactions.
More advanced API methods allow you to access the stream of I/O in
full detail, as responses are received. To issue command
synchronously use `connector.synchronous', and to "pipeline" the
requests (have multiple requests issued and "in flight"
simultaneously), use `connector.pipeline' (see
`cpppo/server/enip/client/thruput.py')
┌────
│ ap = argparse.ArgumentParser()
│ ap.add_argument( '-d', '--depth', default=0, help="Pipelining depth" )
│ ap.add_argument( '-m', '--multiple', default=0, help="Multiple Service Packet size limit" )
│ ap.add_argument( '-r', '--repeat', default=1, help="Repeat requests this many times" )
│ ap.add_argument( '-a', '--address', default='localhost', help="Hostname of target Controller" )
│ ap.add_argument( '-t', '--timeout', default=None, help="I/O timeout seconds (default: None)" )
│ ap.add_argument( 'tags', nargs='+', help="Tags to read/write" )
│ args = ap.parse_args()
│
│ depth = int( args.depth )
│ multiple = int( args.multiple )
│ repeat = int( args.repeat )
│ operations = client.parse_operations( args.tags * repeat )
│ timeout = None
│ if args.timeout is not None:
│ timeout = float( args.timeout )
│
│ with client.connector( host=args.address, timeout=timeout ) as conn: