ottowayi / pycomm3

A Python Ethernet/IP library for communicating with Allen-Bradley PLCs.
MIT License
397 stars 88 forks source link

CIP IO - EDS #260

Open thalesmaoa opened 1 year ago

thalesmaoa commented 1 year ago

Hi, I'm starting with a Ethernet/IP device which I have a EDS file. After some sniffing using Wireshark, I've noticed that it uses explicit message using UDP.

After a lot of reading, I wasn't able to understand if it is possible to establish this type of connection. Are there any example on how to perform it?

thalesmaoa commented 1 year ago

I figure out how to send a generic message, but I need to open a socket to exchange information over port 2222. I'm having a lot of trouble of doing that.

plc = CIPDriver(host)
plc.open()
response = plc.generic_message(
    service=0x54,
    class_code=ClassCode.connection_manager,
    instance=b"\x01",
    #attribute=b"\x01",
    request_data=b"\x03\xfa\x00\x00\x00\x00\xc1\x61\x83\x6b\x01\x00\x05\x05\x98\xb5\x40\xeb\x01\x00\x00\x00\x10\x27\x00\x00\x10\xc8\x10\x27\x00\x00\x56\xc8\x21\x09\x34\x04\x80\x02\x0c\x00\xd7\xdc\x02\x00\x20\x04\x24\x80\x2c\x70\x2c\x64",
    data_type=REAL,
    connected=False,
    unconnected_send=False,
    route_path=False,
    )
ottowayi commented 1 year ago

Changing the port is easy, use the <ipaddress>:<port> syntax for you host variable. Changing the socket type will require you to subclass the driver and override the socket creation. But, you may not have to do any of that. What you're seeing in the pcaps is probably I/O traffic, do they provide any examples on how to use a MSG instruction in Logix? If so, pycomm3 should work as is. Can you post the EDS?

thalesmaoa commented 1 year ago

Hi @ottowayi , I really appreciate your reply. You are probably right, but I didn't understand it correct. I've attached the eds and the pcap from the PLC (can open using Wireshark). I can see that, after successful reply, it starts to send CIP IO messages.

When I try the same using pycomm3. It acknowledge, but then, it drops.

Expected behavior:

1   0.000000    10.254.1.253    10.254.1.33 TCP 74  36183 → 44818 [SYN] Seq=0 Win=64240 Len=0 MSS=1460 SACK_PERM TSval=1295132108 TSecr=0 WS=128
2   0.000369    10.254.1.33 10.254.1.253    TCP 60  44818 → 36183 [SYN, ACK] Seq=0 Ack=1 Win=2048 Len=0 MSS=1460
3   0.000430    10.254.1.253    10.254.1.33 TCP 54  36183 → 44818 [ACK] Seq=1 Ack=1 Win=64240 Len=0
4   0.019890    10.254.1.253    10.254.1.33 ENIP    82  Register Session (Req), Session: 0x00000000
5   0.020381    10.254.1.33 10.254.1.253    ENIP    82  Register Session (Rsp), Session: 0x05010000
6   0.020426    10.254.1.253    10.254.1.33 TCP 54  36183 → 44818 [ACK] Seq=29 Ack=29 Win=64212 Len=0
7   0.049822    10.254.1.253    10.254.1.33 CIP CM  154 Connection Manager - Forward Open (Assembly)
8   0.050886    10.254.1.33 10.254.1.253    CIP CM  164 Success: Connection Manager - Forward Open (Assembly)
9   0.051623    10.254.1.33 10.254.1.253    CIP I/O 146 Connection: ID=0x6B8361C1, SEQ=0000000000, T->O
10  0.054190    10.254.1.33 10.254.1.253    CIP I/O 146 Connection: ID=0x6B8361C1, SEQ=0000000001, T->O

What I get using pycomm3:

0.049822    10.254.1.253    10.254.1.33 CIP CM  154 Connection Manager - Forward Open (Assembly)
0.050886    10.254.1.33 10.254.1.253    CIP CM  164 Success: Connection Manager - Forward Open (Assembly)

And then it drops due to timeout.

Not sure if it is a socket problem, but I need something to establish. One example is https://github.com/EIPStackGroup/OpENer

EDS_PCAP.zip

ottowayi commented 1 year ago

Oh I see what's happening, as soon as you register a session the device creates an I/O (class 1) connection and starts sending data (that's the traffic you see on port 2222). Unfortunately pycomm3 is only designed for message/class 3 connections. I'm guessing the forward open data you're sending is telling it you want to open an I/O connection.

What data are you expecting to get from this device? If you want to read I/O data, I think it will still work using pycomm3. There isn't any built in functionality for it, like there is for reading tags, but using a generic message should be possible.

Can you try doing:

from pycomm3 import ClassCode, Services, ModuleIdentityObject, CIPDriver

with CIPDriver(host) as plc:
    response = plc.generic_message(
        class_code=ClassCode.identity_object,
        instance=b"\x01",
        service=Services.get_attributes_all,
        data_type=ModuleIdentityObject,
        connected=False,  # try with True as well
        #unconnected_send=False,  # set to True when connected is False and host has a route
                                                     # if host is just an IP, the omit all together
        name="identity",
    )

        print(response)
thalesmaoa commented 1 year ago

Thanks for point it out. I'm still learning from ODVA doc, and yes! I can request a class 1 connection, but I need to open a socket using udp to receive the traffic. Since I can't do that, it just drop due to timeout.

The device gives me Analog Inputs and Outputs. Also it has some Digital Input/Output. I just want to integrate it to NodeRed. It won't be a problem to get an array since I now how to map the Assembly info, but I'm kind of lost here.

I tried as you suggested and I can identify the object:

identity, {'product_code': 56535, 'product_name': 'Cube67+ BN-E V2', 'product_type': 'Communications Adapter', 'revision': {'major': 2, 'minor': 6}, ...}, ModuleIdentityObject(UINT(name='vendor'), UINT(name='product_type'), UINT(name='product_code'), Revision(name='revision'), BYTES(name='status'), UDINT(name='serial'), SHORT_STRING(name='product_name')), None

I tried the assembly:

with CIPDriver(host) as plc:
    response = plc.generic_message(
        class_code=ClassCode.assembly,
        instance=b"\x01",
        service=Services.get_attributes_all,
        data_type=ModuleIdentityObject,
        connected=False,  # try with True as well
        #unconnected_send=False,  # set to True when connected is False and host has a route
                                                     # if host is just an IP, the omit all together
        name="identity",
    )

    print(response)
identity, None, ModuleIdentityObject(UINT(name='vendor'), UINT(name='product_type'), UINT(name='product_code'), Revision(name='revision'), BYTES(name='status'), UDINT(name='serial'), SHORT_STRING(name='product_name')), Service not supported

Probably because I'm using ModuleIdentifyObject.

What makes me more confused is that I must configure T->O and O->T. I can't see how is it possible with pycomm3 without request_data hex.

I'm probably missing something.

Do you see the possibility to request data using generic message?

ottowayi commented 1 year ago

Apologies for the late reply, but I think the error response is actually good news. It's saying the get attributes all service isn't supported, but it's a response and that means the device got the request. I'm not too familiar with the assembly object and how it works, but you could try using the get_attribute_single service, keep instance at 1, but set attribute to 3, and remove the data_type or set it to None. If this works, you'll get the data attribute back for instance 1 of the assembly. Without specifying the data type you should get back a bytes object and see how the data structured. If you can determine that you can create a type to use as data_type and have it decode it for you. Or leaving it as None you can handle the decoding yourself.

thalesmaoa commented 1 year ago

It took some time to recover the module to keep testing. I really appreciate your help.

The device response well to pycomm3. From my first message, I can request the CIP IO. The problem is that I can't open UDP socket due to lack of knowledge.

I tried as you suggested but I'm getting an error:

with CIPDriver(host) as plc:
    response = plc.generic_message(
        class_code=ClassCode.assembly,
        instance=b"\x01",
        service=Services.get_attribute_single,
        data_type=None,
        connected=False,  # try with True as well
        attribute=b"\x04",
        #unconnected_send=False,  # set to True when connected is False and host has a route
                                                     # if host is just an IP, the omit all together
        name="identity",
    )

    print(response)
identity, b'', None, Destination unknown, class unsupported, instance undefined or structure element undefined (see extended status) - Extended status out of memory  (05, 00)

To be honest, I had the feeling that I don't know what I am probing, and what I should expect.

ASolchen commented 1 year ago

I did exactly this: https://github.com/ASolchen/pico-eip @ottowayi is correct, it establishes a session via TCP port 44818 and does io (implicit) data back and forth on UDP port 2222. My code is buggy and I don't fully understand all of what is going on, but it does work. I used the example at https://github.com/EIPStackGroup/OpENer to capture packets and replicated it in python. It does the EIP commands "List Services", "Register Session", and "Send RR data". After that the "Scanner" (PLC) and "Adapter" (IO Device) send unsolicited to each other at the agreed upon RPI rate on UDP port 2222. Feel free to let me know if I missed anything or can explain more about how this works.

ASolchen commented 1 year ago

As a follow-up to my last comment, I would love to see this functionality added to pycomm3. The repo I have is actually the other side of the transaction. My code replicates a device ("Adapter" in CIP docs) that a PLC ("Scanner") talks to. If pycomm3 had the connected IO capability it would be the Scanner talking to a device. At least that's how I see the library; as a "Client." Pycomm3 treats the PLC as the server since it opens a port and listens. The flow of info is initiated by Pycomm3. With connected I/O the "Scanner" is the "Client," initiating communications to the "Server" (Adapter). So I see much less effort needed to add the ability to be a scanner, since functions to initiate it are already here using Generic CIP commands, e.g. List Servers 0x0004, or Register Session 0x0065. All that needs to be added is UDP sockets to actually send and receive I/O data. From my testing it, seems to work better to spawn a new thread to handle the I/O data, but this may just because I can't seem to figure out how to handle socket timeouts.

ASolchen commented 1 year ago

I looked a bit at getting pycomm3 to be a scanner. The capture below shows an actual PLC to OpenEner scanner.


        O->T Network Connection Parameters: 0x4826
            0... .... .... .... = Redundant Owner: Non-Redundant (0)
            .10. .... .... .... = Connection Type: Point to Point (2)
            .... 10.. .... .... = Priority: Scheduled (2)
            .... ..0. .... .... = Connection Size Type: Fixed (0)
            .... ...0 0010 0110 = Connection Size: 38 bytes
        T->O RPI: 30.000ms
        T->O Network Connection Parameters: 0x4822
            0... .... .... .... = Redundant Owner: Non-Redundant (0)
            .10. .... .... .... = Connection Type: Point to Point (2)
            .... 10.. .... .... = Priority: Scheduled (2)
            .... ..0. .... .... = Connection Size Type: Fixed (0)
            .... ...0 0010 0010 = Connection Size: 34 bytes
        Transport Type/Trigger: 0x81, Direction: Server, Trigger: Cyclic, Class: 1
        Connection Path Size: 15 words

This is in the net_params of the _fowardopen method. Unfortunately, these seem to be hard-coded in the method: init_net_params = 0b_0100_0010_0000_0000 # CIP Vol 1 - 3-5.5.1.1 This value would set the following: Non-redundant, Point to Point, Low Priority, Fixed Size If we could change this to Scheduled priority we may be able to get it connected. From the captures I have, it only needs this forwardopen command formatted correctly, and the 2 devices start the UDP packets. We would just need to start sending and receiving datagrams.