bnjmnp / pysoem

Cython wrapper for the Simple Open EtherCAT Master Library
MIT License
95 stars 36 forks source link

EL4004 Analog Output Problem: Attempt to write to a read only object #102

Closed WeLikeIke closed 1 year ago

WeLikeIke commented 1 year ago

Hello, Thank you very much for this great library, by reading issues here on github and playing around with the examples I was able to create some scripts that correctly manage Digital I/O and Analog Input...

Regarding Analog Outputs I am stumped, since I'm at my wits end I would like to ask some help to troubleshoot:

What happens? In a configuration connecting EK1100 (Main terminal), EL1018 (Digital In), EL2004 (Digital Out), EL3008 (Analog Input) and EL4004 (Analog Output) I am able to reach OP state with all of them (tested by checking slave.state and slave.read_state()), I am able to read the slave.inputs bytes of the EL1018 and EL3008 and write the slave.outputs (Digital Outputs) of the EL2004. However I am NOT able to write the Analog Outputs of the EL4004.

When I try I receive the following console print: (2, 7186, 0, 100728834, 'Attempt to write to a read only object')

Assumption I made It looks to me that any Beckhoff module that is correctly connected will launch with its own default SDOs and as such would be immediately operational EVEN WITHOUT setting slave.config_func. This assumption seems to hold because, even if the 2 example scripts basic_example.py and minimal_example.py set some PDOs for an EL1259 (Digital Input & Output) and EL3002 (Analog Input), I am able to govern an equivalent I/O WITHOUT setting slave.config_func.

What did I try? Starting from the idea that the above assumption was correct, I suspected that the EL4004 that I was using was faulty, afterall it was telling the master that it was in OP state but could not be operated. That was, unfortunately not the case, I had another couple of EL4004 that I could play with and nothing changed.

Then I thought that it might be a problem with the EL4004 in general, but upon swapping it with another Analog Output module, the EL4182, the same behaviour presented itself.

At this point I am of the opinion that the above assumption is incorrect, as such I started suspecting that the EL4004, or maybe Analog outputs in general, would require the use of a slave.config_func to properly enter operational mode. But no matter what I tried, every meaningful configuration would require the use of slave.sdo_write(), which will give the usual (2, 7186, 0, 100728834, 'Attempt to write to a read only object') error.

I don't have a TwinCat license from Beckhoff but I was able to find a "ethercat circuit debugger" that would let me manually read the inputs and write the outputs of an EtherCat circuit. This completely excluded the possibility of a faulty I/O module, since using this debugger I was able to write into the Analog Outputs from its GUI.

I was even able to contact the man that created the above mentioned program and he concluded that it might be a problem with the initial handshake that the master executes to know in which order the slaves are configured in the EtherCat circuit.

If his hunch is correct, it might be a problem with the library since I do not directly deal with the starting handshake myself.

Minimal reproducible example

import sys
import time
import pysoem

# CONSTANTS
# Chosen Ethernet network adapter (through trial and error)
ETH = "Realtek PCIe GbE Family Controller"
# Known Id for Beckhoff products in the EtherCAT protocol
BECKHOFF_VENDOR_ID = 2
# EtherCAT states
INIT   = pysoem.INIT_STATE
PREOP  = pysoem.PREOP_STATE
SAFEOP = pysoem.SAFEOP_STATE
OP     = pysoem.OP_STATE
# Analog resolution
INT_TO_VOLT = 10.0 / 2 ** 15

# Function to correctly return the network adapter associated with the current laptop's rightside Ethernet port
def just_connect():
    adapters = pysoem.find_adapters()
    for a in adapters:
        if a.desc == ETH:
            return a.name

    raise ConnectionError("Could not find the chosen Network adapter ({})".format(ETH))

# Function to display the name and number of I/O bytes that a slave has available
# Bytes are not equal to I/O in general
def io_print(slave: pysoem.pysoem.CdefSlave):
    print("{} I/O: {}/{}".format(slave.name, len(slave.input), len(slave.output)))

# Function to transform given bytes into a list containing which Digital Inputs are on
def bytes_to_Din(b: bytes, n_bytes: int, n_Din: int):
    if len(b) != n_bytes:
        raise Exception("Mismatch between the number of given bytes ({}) and the number of expected bytes ({})".format(len(b), n_bytes))

    v = int(b.hex(), 16)

    res = []
    for i in range(n_Din):
        res.append(v % 2)
        v = v // 2
    return res

# Function to transform given list of Digital Outputs into bytes to turn on
def Dout_to_bytes(s: list, n_bytes: int, n_Dout: int):
    if len(s) != n_Dout:
        raise Exception("Mismatch between the number of given ON/OFFs ({}) and the number of expected Digital outputs ({})".format(len(l), n_Dout))

    res = ""
    for i in s:
        res = str(i) + res
    return int(res, 2).to_bytes(n_bytes, "big")

# Function to transform given bytes into a list representing how much voltage is present in each Analog Input
def bytes_to_Ain(b: bytes, n_bytes: int, n_Ain: int):
    if len(b) != n_bytes:
        raise Exception("Mismatch between the number of given bytes ({}) and the number of expected bytes ({})".format(len(b), n_bytes))

    res = []
    for i in range(n_Ain):
        v = b[::-1][-4*(i+1):-4*i -2]
        res.append(int(v.hex(), 16) * INT_TO_VOLT)
    return res

# Function to transform given list of Analog Outputs into bytes to turn on
def Aout_to_bytes(s: list, n_bytes: int, n_Aout: int):
    if len(s) != n_Aout:
        raise Exception("Mismatch between the number of given voltages ({}) and the number of expected Analog outputs ({})".format(len(l), n_Aout))

    res = bytes()
    for i in s:
        v = int(i / INT_TO_VOLT)
        if v >= 2**16:
            v = 2**16 -1
        res = v.to_bytes(2, "big") + res
    print(res)
    return res

# Function to obtain the correct product code from Beckhoff module name
# Raises various exceptions if the format is incorrect
def get_product_code(name: str):
    left  = name[:2].upper()
    right = 65536 * int(name[2:])

    if left == "EK":
        right += 11346
    elif left == "EL":
        right += 12370
    else:
        raise Exception("{} starts with {} but should start with either 'EK' or 'EL'".format(name, left))

    return right

# Function to obtain the Beckhoff module name from its product code
def get_product_name(code: int):
    left  = code % 65536
    right = code // 65536

    if left == 11346:
        left = "EK"
    elif left == 12370:
        left = "EL"
    else:
        raise Exception("{} does not correspond to any Beckhoff module".format(code))

    return left + str(right)

# Function to check if the complete EtherCAT circuit has reached the same state
def check_circuit(master: pysoem.pysoem.Master, state: int):
    # Small delay to give the slaves some time to adjust
    time.sleep(0.2)
    # Force the master to update its own state
    master.state_check(state, 50000)

    alarm = False
    master.read_state()
    for slave in master.slaves:
        print("{} state is {}".format(slave.name, slave.state))
        if slave.state != state:
            print("{} alarm state is {} ({})".format(slave.name, pysoem.al_status_code_to_string(slave.al_status), hex(slave.al_status)))
            alarm = True

    if alarm or master.state != state:
        raise Exception("Not all slaves reached the {} state.".format(state))

# Function to open an EtherCAT circuit and return the master
# The function is small bu useful for error handling
def open_circuit(net_interface: str):
    # Attempt to open an EtherCAT communication
    master = pysoem.Master()
    master.open(net_interface)
    return master

# Function to correctly close an EtherCAT circuit, we notify possible slaves that the master will turn off before closing
def close_circuit(master: pysoem.pysoem.Master):
    master.state = INIT
    master.write_state()
    master.close()

def EL3008_setup(slave_pos: int):
    slave = master.slaves[slave_pos]
    slave.sdo_write(0x1c12, 0, b"\x00")
    slave.sdo_write(0x1c13, 0, b"\x08")
    slave.sdo_write(0x1c13, 1, b"\x00\x1a")
    slave.sdo_write(0x1c13, 2, b"\x02\x1a")
    slave.sdo_write(0x1c13, 3, b"\x04\x1a")
    slave.sdo_write(0x1c13, 4, b"\x06\x1a")
    slave.sdo_write(0x1c13, 5, b"\x08\x1a")
    slave.sdo_write(0x1c13, 6, b"\x0a\x1a")
    slave.sdo_write(0x1c13, 7, b"\x0c\x1a")
    slave.sdo_write(0x1c13, 8, b"\x0e\x1a")

def EL4004_setup(slave_pos: int):
    slave = master.slaves[slave_pos]
    slave.sdo_write(0x1c12, 0, b"\x04")
    slave.sdo_write(0x1c12, 1, b"\x00\x16")
    slave.sdo_write(0x1c12, 2, b"\x01\x16")
    slave.sdo_write(0x1c12, 3, b"\x02\x16")
    slave.sdo_write(0x1c12, 4, b"\x03\x16")
    slave.sdo_write(0x1c13, 0, b"\x00")

def EL4004_safe_setup(slave_pos: int):
    slave = master.slaves[slave_pos]
    count = 0
    for obj in slave.od:
        old = slave.sdo_read(obj.index, 0)
        try:
            tmp = (int(old.hex(), 16) + 1).to_bytes(len(a), 'big')
            slave.sdo_write(obj.index, 0, tmp)
            print("{}: was: {} and became: {}".format(hex(obj.index), old, slave.sdo_read(obj.index, 0)))
        except:
            print("{}) Could not write over main sdo ({}) for: {}".format(count, hex(obj.index), slave.name))
            count += 1

        for i, entry in enumerate(obj.entries):
            try:
                if entry.data_type > 0 and entry.bit_length > 0:
                    old = slave.sdo_read(obj.index, i)
                    slave.sdo_write(obj.index, i, tmp)
                    print("{}:{} was: {} and became: {}".format(hex(obj.index), i, old, slave.sdo_read(obj.index, i)))
            except:
                print("{}) Could not write over subindexed sdo ({}:{}) for: {}".format(count, hex(obj.index), i, slave.name))
                count += 1

# Function to setup the circuit into accepting bytes exchange
def prep(master: pysoem.pysoem.Master, check_expected: bool):
    if check_expected:
        expected_slaves = ["EK1100", "EL4004", "EL3008"]

    # config_init returns the number of slaves found
    if master.config_init() > 0:
        print("{} slaves found and configured".format(len(master.slaves)))

        if check_expected:
            for i, slave in enumerate(master.slaves):
                if slave.man != BECKHOFF_VENDOR_ID or slave.id != get_product_code(expected_slaves[i]):
                    raise Exception("Incorrect slave at position number {}, expected {} but found {} instead.".format(i, expected_slaves[i], get_product_name(slave.id)))

        master.slaves[1].config_func = EL4004_safe_setup
        master.slaves[2].config_func = EL3008_setup

        if check_expected:
           master.slaves[1].config_func = EL4004_setup

        # For the passage from the PREOP_STATE to the SAFEOP_STATE each slave needs to call its config_func if one exists
        # We can set each slaves' config_func with slave.config_func = fun
        # They are then all called with master.config_map()
        master.config_map()

        # Here the circuit should be in the SAFEOP_STATE
        check_circuit(master, SAFEOP)

        # For the passage from the SAFEOP_STATE to the OP_STATE we have to manually trigger a master.write_state()
        master.state = OP
        master.write_state()

        # Here the circuit should be in the OP_STATE
        check_circuit(master, OP)

    else:
        raise Exception("No slaves found")

# Function containing the actual execution loop which can be interrupted with Ctrl+C
def test(master: pysoem.pysoem.Master):
    for slave in master.slaves:
        io_print(slave)

    analog = [0.25] * 4
    try:
        while True:
            # Run cycle for data exchange
            master.send_processdata()
            master.receive_processdata(10000)

            #master.slaves[1].output = Dout_to_bytes(counter, 1, 4)
            #print(bytes_to_Ain(master.slaves[2].input, 32, 8))
            #master.slaves[3].output = Aout_to_bytes(analog, 8, 4)
            #print(bytes_to_Din(master.slaves[4].input, 1, 8))

            #print(bytes_to_Ain(master.slaves[1].input, 32, 8))
            master.slaves[1].output = Aout_to_bytes(analog, 8, 4)

            if analog[0] >= 10.0:
                analog = [0] * 4
            else:
                analog[0] += 0.25
                analog[1] += 0.25
                analog[2] += 0.25
                analog[3] += 0.25

            # Timestepping, if absent the signal would be continuous
            # For example, for leaving a led on
            time.sleep(0.3)

    except KeyboardInterrupt:
        # ctrl-C abort handling
        print("Stopped")

if __name__ == "__main__":
    print("Test example")
    try:
        master = open_circuit(just_connect())
        prep(master, len(sys.argv) > 1)
        test(master)
        close_circuit(master)
    except ConnectionError as ex:
        print(ex)
        sys.exit(1)
    except Exception as ex:
        close_circuit(master)
        print(ex)
        sys.exit(1)

Maybe "minimal" is incorrect because I use many utility functions but I think there should be no problems here.

To reproduce Setup a EtherCat circuit as follows: EK1100 - EL4004 - EL3008

In the script above substitute the string constant ETH with the name of your Network Interface (on Windows it works, I don't know on Linux, you might need to rewrite the initial connection of the master)

Running the script above without arguments will print all the times when there is a writable SDO but it cannot be written because of the usual (2, 7186, 0, 100728834, 'Attempt to write to a read only object') error, it also shows that I have NO issue using a similar configuration function to setup the EL3008.

Running the script with any argument, doesn't matter what, would trigger the usage of the EL4004_setup function instead of the EL4004_safe_setup function as the master.slaves[1].config_func, showing the usual error in the console.

Additional info This is the page relative to the Error given by the Beckhoff module: https://infosys.beckhoff.com/english.php?content=../content/1033/ethercatsystem/1037010571.html&id=

This is the page relative to the EL4004, which gives some information regarding SDOs, but again I cannot write to any of them: https://infosys.beckhoff.com/english.php?content=../content/1033/el6695/1317558667.html&id=

And here is the display of the EL4004, it is written that when the module is in the OP state the leds would be all green, but my EL4004 (even if the slave.state tells me that it is in OP) is continuously blinking which would indicate (if this is to be trusted) that it is still in PREOP: https://infosys.beckhoff.com/english.php?content=../content/1033/ep9576-1032/9905045643.html&id=

Let me know if there is any imformation that I can provide, I would be REALLY glad if you could help me troubleshoot this, I have been wasting 3 days on it...

Thanks for your time

bnjmnp commented 1 year ago

Looks like the EL4004 has fixed PDOs, thus there is no config_func needed. This is why you get the SDO error (Attempt to write ..).

I think the LEDs on the EL4004 highly trustworthy, if they don't indicate OP state the device is very likely not in OP state. It could be that initially your Python script is able to put the EL4004 into OP state, but it is falling back into SafeOP or PreOP state after a while. You may try to read the state a little later to confirm that.

To create a less complex example you could remove the other three EtherCAT terminals.

WeLikeIke commented 1 year ago

Thank you very much for your response.

The actual problem was that writing to analog output takes a bit longer than 1 clock cycle to fully output 10V.

This meant that having in my code this loop for data exchange:

while True:
    master.send_processdata()
    master.receive_processdata(10000)

    master.slaves[1].output = Aout_to_bytes(analog, 8, 4)

    if analog[0] >= 10.0:
        analog = [0] * 4
    else:
        analog[0] += 0.25
        analog[1] += 0.25
        analog[2] += 0.25
        analog[3] += 0.25

     # Timestepping, if absent the signal would be continuous
     # For example, for leaving a led on
     time.sleep(0.3)

I was interrupting the main data exchange for 0.3 seconds.

What I was not aware completely is that doing so would reset all digital and analog outputs to their default (off) state.

By using the same code but making the exchange continuous (removing the time.sleep(0.3) instruction) I was also able to correctly output between 0-10V and the lights on the EL4004 would behave as expected: turned on and NOT blinking.

Anyway, thanks again for the library and your time, I can close this now 👍