bnjmnp / pysoem

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

How to pack / unpack single `BIT`s in PDO `input` / `output` #141

Closed RobertoRoos closed 2 weeks ago

RobertoRoos commented 1 month ago

Today I have been struggling to control a stepper motor using a Beckhoff EL7031-0030 terminal.

The problem I'm facing now I don't know how to unpack the EtherCAT slave outputs and how to pack the slave inputs, so I'm not sure if I'm correctly interfacing with the device.

I'm sticking with the default PDO entries for now, which include many BIT types. And I can't find how those translate to the bytes in the input and output.

If somebody can point me to another full working example, in pysoem or SOEM itself, I'd also appreciated it.

The full code I have now: ```python from typing import Optional from time import sleep, time import pysoem import ctypes import struct class PDOInput(ctypes.Structure): """Process data object from the slave to the master.""" _fields_ = [ ("states1", ctypes.c_uint8), ("states2", ctypes.c_uint8), ("counter_value", ctypes.c_uint16), ("latch_value", ctypes.c_uint16), ("states3", ctypes.c_uint8), ("states4", ctypes.c_uint8), ] class PDOOutput(ctypes.Structure): """Process data object from the master to the slave.""" _fields_ = [ # ("dummy", ctypes.c_uint16), ("states1", ctypes.c_uint8), ("set_counter_value", ctypes.c_uint16), ("states2", ctypes.c_uint8), ] class MotorNode: def __init__(self): self.adapter_name = "\\Device\\NPF_{2D594793-2E69-4C58-90F4-3164E860643B}" self._master: Optional[pysoem.Master] = pysoem.Master() self._slave_motor = None self.start_time = 0 self._output_bytes = PDOOutput() def __del__(self): self.close() def run(self): try: self.setup() while True: self.loop() finally: self.close() def on_config_el7031(self, _slave_idx): # Default PDO would be velocity pass def setup(self): self._master = pysoem.Master() self._master.open(self.adapter_name) dev_count = self._master.config_init() if dev_count <= 0: raise RuntimeError("Could not find any EtherCAT devices") for slave in self._master.slaves: if "EL7031" in slave.name: self._slave_motor = slave if self._slave_motor: self._slave_motor.config_func = self.on_config_el7031 # Move from PREOP to SAFEOP state - each slave's config_func is called: self._master.config_map() self._assert_state(pysoem.SAFEOP_STATE) self._master.state = pysoem.OP_STATE self._master.write_state() self._assert_state(pysoem.OP_STATE, 1_000_000) print("All devices in 'OP' state") # Trigger first update to initialize: self._master.send_processdata() self._master.receive_processdata() self.start_time = time() def loop(self): if time() - self.start_time > 1.0: self._output_bytes.states2 = 0b101 # <-- I would expect the drive to enable now, but it doesn't... self._slave_motor.output = bytes(self._output_bytes) self._master.send_processdata() self._master.receive_processdata() full_input = self._slave_motor.input pdo_in = PDOInput.from_buffer_copy(full_input) # ^ I'm not sure this object is a correct map of the bytes... stat = self._slave_motor.al_status desc = pysoem.al_status_code_to_string(stat) # print(f"{self._slave_motor.name} al status: {hex(stat)} ({desc})") sleep(0.01) def close(self): if self._master: self._master.close() self._master = None def _assert_state(self, state, timeout: Optional[int] = None): """Check master state and give verbose error if state is not met.""" args = (state,) if timeout is not None: args += (timeout,) if self._master.state_check(*args) != state: self._master.read_state() msg = "" for slave in self._master.slaves: if slave.state != state: msg += slave.name + " - " + hex(slave.state) + "," desc = pysoem.al_status_code_to_string(slave.al_status) print(f"{slave.name} al status: {hex(slave.al_status)} ({desc})") raise RuntimeError(f"Not all slaves reached state {hex(state)}: {msg}") def main(): node = MotorNode() try: node.run() except KeyboardInterrupt: pass # Ignore if __name__ == "__main__": main() ```

The slave inputs as displayed in TwinCAT: image image image

RobertoRoos commented 1 month ago

I've pretty much figured this out now, mostly through lot's of trial-and-error.

So in a nutshell: the PDO input and output is packed in a way I can't deduce from the EL7041 documentation, but I can copy the information from the TwinCAT project. When clicking on an entry, e.g. "Sync Error", there is the "address" field. For this entry the address "40.7". The .7 indicates bit number 7 is being used (the last bit, because counting starts at 0). The 40 indicates the byte number, although it starts at an offset. The lowest address value is 39, so I'm considering address 39 as byte 0, 40 as byte 1, etc. So we can follow this to determine the entire PDO entry.

ctypes.Structure bit fields are well suited to this. See https://docs.python.org/3/library/ctypes.html#bit-fields-in-structures-and-unions

A complete example can be found in my comment here: https://github.com/bnjmnp/pysoem/issues/135#issuecomment-2182502871

bnjmnp commented 3 weeks ago

Nice job, this is great example. Can we close this issue?

RobertoRoos commented 2 weeks ago

Yeah, we can close this. I'm assuming there is not a less tedious and manual approach to this that I don't know.