import atexit
import os
import threading
import time
from collections import OrderedDict, defaultdict
from enum import Enum
from threading import Thread
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
import ingenialogger
try:
import pysoem
except ImportError as ex:
pysoem = None
pysoem_import_error = ex
if TYPE_CHECKING:
from pysoem import CdefSlave
from dataclasses import dataclass, field
from ingenialink.constants import ECAT_STATE_CHANGE_TIMEOUT_US
from ingenialink.ethercat.servo import EthercatServo
from ingenialink.exceptions import (
ILError,
ILFirmwareLoadError,
ILStateError,
ILWrongWorkingCountError,
)
from ingenialink.network import NetDevEvt, NetProt, NetState, Network, SlaveInfo
logger = ingenialogger.get_logger(__name__)
# Holds a reference to the Ethercat network (used to handle no-GIL cases)
ETHERCAT_NETWORK_REFERENCES: set["EthercatNetwork"] = set()
[docs]
def set_network_reference(network: "EthercatNetwork") -> None:
"""Adds a reference to an EtherCAT network.
Args:
network: network.
"""
global ETHERCAT_NETWORK_REFERENCES
ETHERCAT_NETWORK_REFERENCES.add(network)
[docs]
@atexit.register # Remove all references upon normal program termination
def release_network_reference(network: Optional["EthercatNetwork"] = None) -> None:
"""Releases a network reference.
If `network` is not provided, all references will be removed.
Args:
network: network object.
Raises:
RuntimeError: if the specified network is not on the list.
"""
global ETHERCAT_NETWORK_REFERENCES
if network is None:
ETHERCAT_NETWORK_REFERENCES.clear()
elif network not in ETHERCAT_NETWORK_REFERENCES:
raise RuntimeError("Could not release reference of network.")
else:
ETHERCAT_NETWORK_REFERENCES.remove(network)
[docs]
@dataclass(frozen=True)
class GilReleaseConfig:
"""Configuration of pysoem functions that have GIL release control."""
config_init: Optional[bool] = None
sdo_read_write: Optional[bool] = None
foe_read_write: Optional[bool] = None
send_receive_processdata: Optional[bool] = None
_always_release: bool = field(init=False, default=False)
@property
def always_release(self) -> bool:
"""Returns True if the GIL should be released for all functions, False otherwise."""
return self._always_release
[docs]
@classmethod
def always(cls) -> "GilReleaseConfig":
"""Releases the GIL from all functions.
Returns:
GIL configuration.
"""
instance = cls(
config_init=True,
sdo_read_write=True,
foe_read_write=True,
send_receive_processdata=True,
)
object.__setattr__(instance, "_always_release", True) # frozen instance
return instance
[docs]
class SlaveState(Enum):
"""EtherCAT state enum."""
NONE_STATE = 0
INIT_STATE = 1
PREOP_STATE = 2
BOOT_STATE = 3
SAFEOP_STATE = 4
OP_STATE = 8
ERROR_STATE = 16
PREOP_ERROR_STATE = PREOP_STATE + ERROR_STATE
SAFEOP_ERROR_STATE = SAFEOP_STATE + ERROR_STATE
[docs]
class NetStatusListener(Thread):
"""Network status listener thread to check if the drive is alive.
Args:
network: Network instance of the EtherCAT communication.
"""
def __init__(self, network: "EthercatNetwork", refresh_time: float = 0.25):
super().__init__()
self.__network = network
self.__refresh_time = refresh_time
self.__stop = False
self._ecat_master = self.__network._ecat_master
[docs]
def run(self) -> None:
"""Check the network status."""
while not self.__stop:
self._ecat_master.read_state()
for servo in self.__network.servos:
slave_id = servo.slave_id
servo_state = self.__network.get_servo_state(slave_id)
is_servo_alive = servo.slave.state != pysoem.NONE_STATE
if not is_servo_alive and servo_state == NetState.CONNECTED:
self.__network._notify_status(slave_id, NetDevEvt.REMOVED)
self.__network._set_servo_state(slave_id, NetState.DISCONNECTED)
if (
is_servo_alive
and servo_state == NetState.DISCONNECTED
and self.__network._recover_from_disconnection()
):
self.__network._notify_status(slave_id, NetDevEvt.ADDED)
self.__network._set_servo_state(slave_id, NetState.CONNECTED)
time.sleep(self.__refresh_time)
[docs]
def stop(self) -> None:
"""Check the network status."""
self.__stop = True
[docs]
class EthercatNetwork(Network):
"""Network for all EtherCAT communications.
Args:
interface_name: Interface name to be targeted.
connection_timeout: Time in seconds of the connection timeout.
overlapping_io_map: Map PDOs to overlapping IO map.
gil_release_config: configures which functions should release the GIL.
Raises:
ImportError: WinPcap is not installed
"""
MANUAL_STATE_CHANGE = 1
DEFAULT_ECAT_CONNECTION_TIMEOUT_S = 1
ECAT_PROCESSDATA_TIMEOUT_S = 0.1
EXPECTED_WKC_PROCESS_DATA = 3
DEFAULT_FOE_PASSWORD = 0x70636675
__FOE_WRITE_TIMEOUT_US = 500_000
__FOE_RECOVERY_TIMEOUT_S = 90
__FOE_RECOVERY_SLEEP_S = 5
__FORCE_BOOT_PASSWORD = 0x424F4F54
__FORCE_COCO_BOOT_IDX = 0x5EDE
__FORCE_COCO_BOOT_SUBIDX = 0x00
__FORCE_BOOT_SLEEP_TIME_S = 5
__DEFAULT_FOE_FILE_NAME = "firmware_file"
__MAX_FOE_TRIES = 2
def __init__(
self,
interface_name: str,
connection_timeout: float = DEFAULT_ECAT_CONNECTION_TIMEOUT_S,
overlapping_io_map: bool = True,
gil_release_config: GilReleaseConfig = GilReleaseConfig(),
):
if not pysoem:
raise pysoem_import_error
super().__init__()
self.interface_name: str = interface_name
self.servos: list[EthercatServo] = []
self.__listener_net_status: Optional[NetStatusListener] = None
self.__observers_net_state: dict[int, list[Any]] = defaultdict(list)
self._ecat_master: pysoem.CdefMaster = pysoem.Master()
self.__gil_release_config = gil_release_config
self._ecat_master.always_release_gil = self.__gil_release_config.always_release
timeout_us = int(1_000_000 * connection_timeout)
self.update_sdo_timeout(timeout_us, timeout_us)
self._ecat_master.manual_state_change = self.MANUAL_STATE_CHANGE
self._overlapping_io_map = overlapping_io_map
self.__is_master_running = False
self.__last_init_nodes: list[int] = []
self._lock = threading.Lock()
set_network_reference(network=self)
[docs]
def update_sdo_timeout(self, sdo_read_timeout: int, sdo_write_timeout: int) -> None:
"""Update SDO timeouts for all the drives.
Args:
sdo_read_timeout: timeout for SDO read access in us
sdo_write_timeout: timeout for SDO write access in us
"""
self._ecat_master.sdo_read_timeout = sdo_read_timeout
self._ecat_master.sdo_write_timeout = sdo_write_timeout
[docs]
@staticmethod
def update_pysoem_timeouts(
ret: int, safe: int, eeprom: int, tx_mailbox: int, rx_mailbox: int, state: int
) -> None:
"""Update pysoem timeouts.
Args:
ret: new ret timeout.
safe: new safe timeout.
eeprom: new EEPROM access timeout.
tx_mailbox: new Tx mailbox cycle timeout.
rx_mailbox: new Rx mailbox cycle timeout.
state: new status check timeout.
"""
if not pysoem:
raise pysoem_import_error
pysoem.settings.timeouts.ret = ret
pysoem.settings.timeouts.safe = safe
pysoem.settings.timeouts.eeprom = eeprom
pysoem.settings.timeouts.tx_mailbox = tx_mailbox
pysoem.settings.timeouts.rx_mailbox = rx_mailbox
pysoem.settings.timeouts.state = state
@staticmethod
def __get_foe_error_message(error_code: int) -> str:
"""Error message associated with an error code.
Args:
error_code: FoE error code.
Returns:
Error message.
"""
# Error codes taken from SOEM source code.
# https://github.com/OpenEtherCATsociety/SOEM/blob/v1.4.0/soem/ethercatfoe.c#L199
if error_code == -3:
return "Unexpected mailbox received"
if error_code == -5:
return "FoE error"
if error_code == -6:
return "Buffer too small"
if error_code == -7:
return "Packet number error"
if error_code == -10:
return "File not found"
return f" Error code: {error_code}."
[docs]
def scan_slaves(self) -> list[int]:
"""Scans for slaves in the network.
Scanning of slaves cannot be done if a slave is already
connected to the network.
Returns:
List containing all the detected slaves.
Raises:
ILError: If any slaves is already connected.
"""
if self.servos:
raise ILError("Some slaves are already connected")
is_master_running_before_scan = self.__is_master_running
if not is_master_running_before_scan:
self._start_master()
self.__init_nodes()
slaves_found = self.__last_init_nodes
if not is_master_running_before_scan:
self.close_ecat_master(release_reference=False)
return slaves_found
[docs]
def scan_slaves_info(self) -> OrderedDict[int, SlaveInfo]:
"""Scans for slaves in the network and return an ordered dict with the slave information.
Returns:
Ordered dict with the slave information.
"""
slave_info: OrderedDict[int, SlaveInfo] = OrderedDict()
try:
slaves = self.scan_slaves()
except ILError:
return slave_info
for slave_id in slaves:
slave = self._ecat_master.slaves[slave_id - 1]
slave_info[slave_id] = SlaveInfo(slave.id, slave.rev)
return slave_info
def __init_nodes(self, *, release_gil: Optional[bool] = None) -> None:
"""Init all the nodes and set already connected nodes to PreOp state.
Also fill `__last_init_nodes` attribute.
Args:
release_gil: used to overwrite the GIL release configuration.
True to release the GIL, False otherwise.
If not specified, default GIL release configuration will be used.
"""
if release_gil is None:
release_gil = self.__gil_release_config.config_init
self._lock.acquire()
nodes = self._ecat_master.config_init(release_gil=release_gil)
self._lock.release()
if len(self.servos):
self._change_nodes_state(self.servos, pysoem.PREOP_STATE)
if nodes is not None:
self.__last_init_nodes = list(range(1, nodes + 1))
[docs]
def connect_to_slave(
self,
slave_id: int,
dictionary: str,
servo_status_listener: bool = False,
net_status_listener: bool = False,
) -> EthercatServo:
"""Connects to a drive through a given slave number.
Args:
slave_id: Targeted slave to be connected.
dictionary: Path to the dictionary file.
servo_status_listener: Toggle the listener of the servo for
its status, errors, faults, etc.
net_status_listener: Toggle the listener of the network
status, connection and disconnection.
Raises:
ValueError: If the slave ID is not valid.
ILError: If no slaves are found.
ILStateError: If slave can not reach PreOp state
Returns:
ethercat servo.
"""
if not isinstance(slave_id, int) or slave_id < 0:
raise ValueError("Invalid slave ID value")
if not self.__is_master_running:
self._start_master()
if slave_id not in self.__last_init_nodes:
self.__init_nodes()
if len(self.__last_init_nodes) == 0:
raise ILError("Could not find any slaves in the network.")
if slave_id not in self.__last_init_nodes:
raise ILError(f"Slave {slave_id} was not found.")
slave = self._ecat_master.slaves[slave_id - 1]
servo = EthercatServo(
slave,
slave_id,
dictionary,
servo_status_listener,
sdo_read_write_release_gil=self.__gil_release_config.sdo_read_write,
)
if not self._change_nodes_state(servo, pysoem.PREOP_STATE):
if servo_status_listener:
servo.stop_status_listener()
raise ILStateError("Slave can not reach PreOp state")
servo.reset_pdo_mapping()
self.servos.append(servo)
self._set_servo_state(slave_id, NetState.CONNECTED)
if net_status_listener:
self.start_status_listener()
return servo
[docs]
def close_ecat_master(self, release_reference: bool = True) -> None:
"""Closes the connection with the EtherCAT master.
Args:
release_reference: Whether to release the network reference.
If the network will be reused afterward it should be set to False.
"""
self._lock.acquire()
self._ecat_master.close()
self._lock.release()
self.__is_master_running = False
self.__last_init_nodes = []
if release_reference:
release_network_reference(network=self)
[docs]
def disconnect_from_slave(self, servo: EthercatServo) -> None: # type: ignore [override]
"""Disconnects the slave from the network.
Args:
servo: Instance of the servo connected.
"""
if not self._change_nodes_state(servo, pysoem.INIT_STATE):
logger.warning("Drive can not reach Init state")
servo.teardown()
self.servos.remove(servo)
if not self.servos:
self.stop_status_listener()
self.close_ecat_master()
[docs]
def config_pdo_maps(self) -> None:
"""Configure the PDO maps.
It maps the PDO maps of each slave and sets its state to SafeOP.
"""
if self._overlapping_io_map:
self._ecat_master.config_overlap_map()
else:
self._ecat_master.config_map()
[docs]
def start_pdos(self, timeout: float = 1.0) -> None:
"""Set all slaves with mapped PDOs to Operational State.
Args:
timeout: timeout in seconds to reach Op state, 1.0 seconds by default.
Raises:
ILError: If the RPDO values are not set before starting the PDO exchange process.
ILStateError: If slaves can not reach SafeOp or Op state.
"""
op_servo_list = [servo for servo in self.servos if servo._rpdo_maps or servo._tpdo_maps]
if not op_servo_list:
logger.warning("There are no PDOs assigned to any connected slave.")
return
try:
for servo in op_servo_list:
for rpdo_map in servo._rpdo_maps:
rpdo_map.get_item_bytes()
except ILError as e:
raise ILError(
"The RPDO values should be set before starting the PDO exchange process."
) from e
self.config_pdo_maps()
self._ecat_master.state = pysoem.SAFEOP_STATE
if not self._change_nodes_state(op_servo_list, pysoem.SAFEOP_STATE):
raise ILStateError("Drives can not reach SafeOp state")
self._change_nodes_state(op_servo_list, pysoem.OP_STATE)
init_time = time.time()
while not self._check_node_state(op_servo_list, pysoem.OP_STATE):
self.send_receive_processdata()
if timeout < time.time() - init_time:
raise ILStateError("Drives can not reach Op state")
[docs]
def stop_pdos(self) -> None:
"""For all slaves in OP or SafeOp state, set state to PreOp."""
self._ecat_master.read_state()
op_servo_list = [
servo
for servo in self.servos
if servo.slave.state in [pysoem.OP_STATE, pysoem.SAFEOP_STATE]
]
if len(op_servo_list) == 0:
return
if not self._change_nodes_state(op_servo_list, pysoem.INIT_STATE):
logger.warning("Not all drives could reach the Init state")
self.__init_nodes()
[docs]
def send_receive_processdata(
self, timeout: float = ECAT_PROCESSDATA_TIMEOUT_S, *, release_gil: Optional[bool] = None
) -> None:
"""Send and receive PDOs.
Args:
timeout: receive processdata timeout in seconds, 0.1 seconds by default.
release_gil: used to overwrite the GIL release configuration.
True to release the GIL, False otherwise.
If not specified, default GIL release configuration will be used.
Raises:
ILWrongWorkingCountError: If processdata working count is wrong
"""
if release_gil is None:
release_gil = self.__gil_release_config.send_receive_processdata
for servo in self.servos:
servo.generate_pdo_outputs()
self._lock.acquire()
if self._overlapping_io_map:
self._ecat_master.send_overlap_processdata()
else:
self._ecat_master.send_processdata(release_gil=release_gil)
processdata_wkc = self._ecat_master.receive_processdata(
timeout=int(timeout * 1_000_000), release_gil=release_gil
)
self._lock.release()
if processdata_wkc != self.EXPECTED_WKC_PROCESS_DATA * (len(self.servos)):
self._ecat_master.read_state()
servos_state_msg = ""
for servo in self.servos:
servos_state_msg += (
f"Slave {servo.slave_id}: state {SlaveState(servo.slave.state).name}"
)
if servo.slave.al_status != 0:
al_status = pysoem.al_status_code_to_string(servo.slave.al_status)
servos_state_msg += f", AL status {al_status}."
else:
servos_state_msg += ". "
raise ILWrongWorkingCountError(
f"Processdata working count is wrong, expected: {self._ecat_master.expected_wkc},"
f" real: {processdata_wkc}. {servos_state_msg}"
)
for servo in self.servos:
servo.process_pdo_inputs()
def _change_nodes_state(
self, nodes: Union["EthercatServo", list["EthercatServo"]], target_state: int
) -> bool:
"""Set ECAT state to target state for all nodes in list.
Args:
nodes: target node or list of nodes
target_state: target ECAT state
Returns:
True if all nodes reached the target state, else False.
"""
node_list = nodes if isinstance(nodes, list) else [nodes]
for drive in node_list:
drive.slave.state = target_state
drive.slave.write_state()
return self._check_node_state(nodes, target_state)
def _check_node_state(
self, nodes: Union["EthercatServo", list["EthercatServo"]], target_state: int
) -> bool:
"""Check ECAT state for all nodes in list.
Args:
nodes: target node or list of nodes
target_state: target ECAT state
Returns:
True if all nodes reached the target state, else False.
"""
if not nodes:
return False
node_list = nodes if isinstance(nodes, list) else [nodes]
self._ecat_master.read_state()
return all(
target_state == drive.slave.state_check(target_state, ECAT_STATE_CHANGE_TIMEOUT_US)
for drive in node_list
)
[docs]
def subscribe_to_status( # type: ignore [override]
self, slave_id: int, callback: Callable[[NetDevEvt], None]
) -> None:
"""Subscribe to network state changes.
Args:
slave_id: Slave ID of the drive to subscribe.
callback: Callback function.
"""
if callback in self.__observers_net_state[slave_id]:
logger.info("Callback already subscribed.")
return
self.__observers_net_state[slave_id].append(callback)
[docs]
def unsubscribe_from_status( # type: ignore [override]
self, slave_id: int, callback: Callable[[str, NetDevEvt], None]
) -> None:
"""Unsubscribe from network state changes.
Args:
slave_id: Slave ID of the drive to subscribe.
callback: Callback function.
"""
if callback not in self.__observers_net_state[slave_id]:
logger.info("Callback not subscribed.")
return
self.__observers_net_state[slave_id].remove(callback)
[docs]
def start_status_listener(self) -> None:
"""Start monitoring network events (CONNECTION/DISCONNECTION)."""
if self.__listener_net_status is None:
listener = NetStatusListener(self)
listener.start()
self.__listener_net_status = listener
[docs]
def stop_status_listener(self) -> None:
"""Stops the NetStatusListener from listening to the drive."""
if self.__listener_net_status is not None:
self.__listener_net_status.stop()
self.__listener_net_status.join()
self.__listener_net_status = None
[docs]
def load_firmware(
self, fw_file: str, boot_in_app: bool, slave_id: int = 1, password: Optional[int] = None
) -> None:
"""Loads a given firmware file to a target slave.
Args:
fw_file: Path to the firmware file.
boot_in_app: True if the application includes the bootloader (i.e, ``fw_file`` extension
is .sfu), False otherwise.
slave_id: Slave ID to which load the firmware file.
password: Password to load the firmware file. If ``None`` the default password will be
used.
Raises:
AttributeError: If the boot_in_app argument is not a boolean.
FileNotFoundError: If the firmware file cannot be found.
ValueError: If the salve ID value is invalid.
ILError: If no slaves could be found in the network.
ILError: If the slave ID couldn't be found in the network.
ILFirmwareLoadError: If no slave is detected.
ILFirmwareLoadError: If the FoE write operation is not successful.
"""
if not isinstance(boot_in_app, bool):
raise AttributeError("The boot_in_app argument should be a boolean.")
if not os.path.isfile(fw_file):
raise FileNotFoundError(f"Could not find {fw_file}.")
if password is None:
password = self.DEFAULT_FOE_PASSWORD
if not isinstance(slave_id, int) or slave_id < 0:
raise ValueError("Invalid slave ID value")
is_master_running_before_loading_firmware = self.__is_master_running
if not is_master_running_before_loading_firmware:
self._start_master()
self.__init_nodes()
if len(self.__last_init_nodes) == 0:
raise ILError("Could not find any slaves in the network.")
if slave_id not in self.__last_init_nodes:
raise ILError(f"Slave {slave_id} was not found.")
slave = self._ecat_master.slaves[slave_id - 1]
for iteration in range(self.__MAX_FOE_TRIES):
if not boot_in_app:
self._force_boot_mode(slave)
self._switch_to_boot_state(slave)
foe_write_result = self._write_foe(slave, fw_file, password)
if foe_write_result > 0:
break
self.__init_nodes()
else:
error_message = (
"The firmware file could not be loaded correctly."
f" {EthercatNetwork.__get_foe_error_message(error_code=foe_write_result)}"
)
raise ILFirmwareLoadError(error_message)
start_time = time.time()
recovered = False
while time.time() < (start_time + self.__FOE_RECOVERY_TIMEOUT_S) and not recovered:
self.__init_nodes()
slave.state = pysoem.PREOP_STATE
slave.write_state()
recovered = (
slave.state_check(pysoem.PREOP_STATE, ECAT_STATE_CHANGE_TIMEOUT_US)
== pysoem.PREOP_STATE
)
time.sleep(self.__FOE_RECOVERY_SLEEP_S)
if recovered:
logger.info("Firmware updated successfully")
else:
logger.info(f"The slave {slave_id} cannot reach the PreOp state.")
if not is_master_running_before_loading_firmware:
self.close_ecat_master(release_reference=False)
def _switch_to_boot_state(self, slave: "CdefSlave") -> None:
"""Transitions the slave to the boot state.
Raises:
ILFirmwareLoadError: if the drive cannot reach the boot state.
"""
slave.state = pysoem.BOOT_STATE
slave.write_state()
if slave.state_check(pysoem.BOOT_STATE, ECAT_STATE_CHANGE_TIMEOUT_US) != pysoem.BOOT_STATE:
raise ILFirmwareLoadError("The drive cannot reach the boot state.")
def _force_boot_mode(self, slave: "CdefSlave") -> None:
"""COMOCO drives need to be forced to boot mode.
Raises:
ILFirmwareLoadError: If there is an error writing to the Boot mode register.
"""
slave.state = pysoem.PREOP_STATE
slave.write_state()
if (
slave.state_check(pysoem.PREOP_STATE, ECAT_STATE_CHANGE_TIMEOUT_US)
== pysoem.PREOP_STATE
):
try:
slave.sdo_write(
self.__FORCE_COCO_BOOT_IDX,
self.__FORCE_COCO_BOOT_SUBIDX,
self.__FORCE_BOOT_PASSWORD.to_bytes(4, "little"),
)
except pysoem.WkcError as e:
raise ILFirmwareLoadError("Error writing to the Boot mode register.") from e
slave.state = pysoem.INIT_STATE
slave.write_state()
slave.state = pysoem.BOOT_STATE
slave.write_state()
time.sleep(self.__FORCE_BOOT_SLEEP_TIME_S)
self.__init_nodes()
def _write_foe(
self,
slave: "CdefSlave",
file_path: str,
password: int,
*,
release_gil: Optional[bool] = None,
) -> int:
"""Write the firmware file via FoE.
Args:
slave: The pysoem slave object.
file_path: The firmware file path.
password: The firmware password.
release_gil: used to overwrite the GIL release configuration.
True to release the GIL, False otherwise.
If not specified, default GIL release configuration will be used.
Returns:
The FOE operation result.
"""
if release_gil is None:
release_gil = self.__gil_release_config.foe_read_write
with open(file_path, "rb") as file:
file_data = file.read()
self._lock.acquire()
r: int = slave.foe_write(
self.__DEFAULT_FOE_FILE_NAME,
password,
file_data,
self.__FOE_WRITE_TIMEOUT_US,
release_gil=release_gil,
)
self._lock.release()
return r
def _start_master(self) -> None:
"""Start the EtherCAT master."""
self._ecat_master.open(self.interface_name)
self.__is_master_running = True
@property
def protocol(self) -> NetProt:
"""NetProt: Obtain network protocol."""
return NetProt.ECAT
[docs]
def get_servo_state(self, servo_id: Union[int, str]) -> NetState:
"""Get the state of a servo that's a part of network.
The state indicates if the servo is connected or disconnected.
Args:
servo_id: The servo's slave ID.
Raises:
ValueError: If the servo ID is not an integer.
Returns:
The servo's state.
"""
if not isinstance(servo_id, int):
raise ValueError("The servo ID must be an int.")
return self._servos_state[servo_id]
def _set_servo_state(self, servo_id: Union[int, str], state: NetState) -> None:
"""Set the state of a servo that's a part of network.
Args:
servo_id: The servo's slave ID.
state: The servo's state.
"""
self._servos_state[servo_id] = state
def _notify_status(self, slave_id: int, status: NetDevEvt) -> None:
"""Notify subscribers of a network state change."""
for callback in self.__observers_net_state[slave_id]:
callback(status)
def _recover_from_disconnection(self) -> bool:
"""Recover the CoE communication after a disconnection.
All the connected slaves need to transitioned to the PreOp state.
Returns:
True if all the connected slaves reach the PreOp state.
"""
self._ecat_master.read_state()
if self._ecat_master.state == pysoem.PREOP_STATE:
return True
self.__init_nodes()
if not self.servos:
log_message = (
"The CoE communication cannot be recovered. No slaves where detected in the network"
)
return False
all_drives_in_preop = self._check_node_state(self.servos, pysoem.PREOP_STATE)
if all_drives_in_preop:
log_message = "CoE communication recovered."
else:
log_message = (
"The CoE communication cannot be recovered. Not all slaves reached the PreOp state"
)
logger.warning(log_message)
return all_drives_in_preop