diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 4089ee1e..3efb3261 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.5, '3.x'] + python-version: [3.6, '3.x'] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index d5f3859d..420d032a 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -21,11 +21,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install setuptools wheel build twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - python setup.py sdist bdist_wheel + python -m build twine upload dist/* diff --git a/.gitignore b/.gitignore index fddae0e9..e8507cc0 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ var/ .installed.cfg *.egg build-deb/ +_version.py # PyInstaller # Usually these files are written by a python script from a template @@ -69,3 +70,6 @@ target/ \.project \.pydevproject + +*.kdev4 +*.kate-swp diff --git a/README.rst b/README.rst index 4a1fd4b1..bdf66701 100644 --- a/README.rst +++ b/README.rst @@ -6,7 +6,7 @@ The aim of the project is to support the most common parts of the CiA 301 standard in a simple Pythonic interface. It is mainly targeted for testing and automation tasks rather than a standard compliant master implementation. -The library supports Python 3.4+. +The library supports Python 3.6+. Features @@ -51,6 +51,10 @@ it in `develop mode`_:: $ cd canopen $ pip install -e . +Unit tests can be run using the pytest_ framework:: + + $ pip install pytest + $ pytest -v Documentation ------------- @@ -165,3 +169,4 @@ logging_ level: .. _Sphinx: http://www.sphinx-doc.org/ .. _develop mode: https://packaging.python.org/distributing/#working-in-development-mode .. _logging: https://docs.python.org/3/library/logging.html +.. _pytest: https://docs.pytest.org/ diff --git a/canopen/__init__.py b/canopen/__init__.py index 2fd09927..a63ecb83 100644 --- a/canopen/__init__.py +++ b/canopen/__init__.py @@ -1,16 +1,14 @@ -from pkg_resources import get_distribution, DistributionNotFound from .network import Network, NodeScanner from .node import RemoteNode, LocalNode from .sdo import SdoCommunicationError, SdoAbortedError -from .objectdictionary import import_od, ObjectDictionary, ObjectDictionaryError +from .objectdictionary import import_od, export_od, ObjectDictionary, ObjectDictionaryError from .profiles.p402 import BaseNode402 - -Node = RemoteNode - try: - __version__ = get_distribution(__name__).version -except DistributionNotFound: + from ._version import version as __version__ +except ImportError: # package is not installed __version__ = "unknown" +Node = RemoteNode + __pypi_url__ = "https://pypi.org/project/canopen/" diff --git a/canopen/emcy.py b/canopen/emcy.py index afb52dde..8964262e 100644 --- a/canopen/emcy.py +++ b/canopen/emcy.py @@ -2,6 +2,7 @@ import logging import threading import time +from typing import Callable, List, Optional # Error code, error register, vendor specific data EMCY_STRUCT = struct.Struct(" "EmcyError": """Wait for a new EMCY to arrive. - :param int emcy_code: EMCY code to wait for - :param float timeout: Max time in seconds to wait + :param emcy_code: EMCY code to wait for + :param timeout: Max time in seconds to wait :return: The EMCY exception object or None if timeout - :rtype: canopen.emcy.EmcyError """ end_time = time.time() + timeout while True: @@ -79,15 +81,15 @@ def wait(self, emcy_code=None, timeout=10): class EmcyProducer(object): - def __init__(self, cob_id): + def __init__(self, cob_id: int): self.network = None self.cob_id = cob_id - def send(self, code, register=0, data=b""): + def send(self, code: int, register: int = 0, data: bytes = b""): payload = EMCY_STRUCT.pack(code, register, data) self.network.send_message(self.cob_id, payload) - def reset(self, register=0, data=b""): + def reset(self, register: int = 0, data: bytes = b""): payload = EMCY_STRUCT.pack(0, register, data) self.network.send_message(self.cob_id, payload) @@ -111,7 +113,7 @@ class EmcyError(Exception): (0xFF00, 0xFF00, "Device Specific") ] - def __init__(self, code, register, data, timestamp): + def __init__(self, code: int, register: int, data: bytes, timestamp: float): #: EMCY code self.code = code #: Error register @@ -121,7 +123,7 @@ def __init__(self, code, register, data, timestamp): #: Timestamp of message self.timestamp = timestamp - def get_desc(self): + def get_desc(self) -> str: for code, mask, description in self.DESCRIPTIONS: if self.code & mask == code: return description diff --git a/canopen/network.py b/canopen/network.py index 70f5c361..04acda18 100644 --- a/canopen/network.py +++ b/canopen/network.py @@ -4,7 +4,7 @@ from collections import MutableMapping import logging import threading -import struct +from typing import Callable, Dict, Iterable, List, Optional, Union try: import can @@ -22,9 +22,12 @@ from .nmt import NmtMaster from .lss import LssMaster from .objectdictionary.eds import import_from_node +from .objectdictionary import ObjectDictionary logger = logging.getLogger(__name__) +Callback = Callable[[int, bytearray, float], None] + class Network(MutableMapping): """Representation of one CAN bus containing one or more nodes.""" @@ -43,8 +46,8 @@ def __init__(self, bus=None): #: Includes at least MessageListener. self.listeners = [MessageListener(self)] self.notifier = None - self.nodes = {} - self.subscribers = {} + self.nodes: Dict[int, Union[RemoteNode, LocalNode]] = {} + self.subscribers: Dict[int, List[Callback]] = {} self.send_lock = threading.Lock() self.sync = SyncProducer(self) self.time = TimeProducer(self) @@ -55,10 +58,10 @@ def __init__(self, bus=None): self.lss.network = self self.subscribe(self.lss.LSS_RX_COBID, self.lss.on_message_received) - def subscribe(self, can_id, callback): + def subscribe(self, can_id: int, callback: Callback) -> None: """Listen for messages with a specific CAN ID. - :param int can_id: + :param can_id: The CAN ID to listen for. :param callback: Function to call when message is received. @@ -67,7 +70,7 @@ def subscribe(self, can_id, callback): if callback not in self.subscribers[can_id]: self.subscribers[can_id].append(callback) - def unsubscribe(self, can_id, callback=None): + def unsubscribe(self, can_id, callback=None) -> None: """Stop listening for message. :param int can_id: @@ -81,7 +84,7 @@ def unsubscribe(self, can_id, callback=None): else: self.subscribers[can_id].remove(callback) - def connect(self, *args, **kwargs): + def connect(self, *args, **kwargs) -> "Network": """Connect to CAN bus using python-can. Arguments are passed directly to :class:`can.BusABC`. Typically these @@ -111,7 +114,7 @@ def connect(self, *args, **kwargs): self.notifier = can.Notifier(self.bus, self.listeners, 1) return self - def disconnect(self): + def disconnect(self) -> None: """Disconnect from the CAN bus. Must be overridden in a subclass if a custom interface is used. @@ -132,7 +135,12 @@ def __enter__(self): def __exit__(self, type, value, traceback): self.disconnect() - def add_node(self, node, object_dictionary=None, upload_eds=False): + def add_node( + self, + node: Union[int, RemoteNode, LocalNode], + object_dictionary: Union[str, ObjectDictionary, None] = None, + upload_eds: bool = False, + ) -> RemoteNode: """Add a remote node to the network. :param node: @@ -142,12 +150,11 @@ def add_node(self, node, object_dictionary=None, upload_eds=False): Can be either a string for specifying the path to an Object Dictionary file or a :class:`canopen.ObjectDictionary` object. - :param bool upload_eds: + :param upload_eds: Set ``True`` if EDS file should be uploaded from 0x1021. :return: The Node object that was added. - :rtype: canopen.RemoteNode """ if isinstance(node, int): if upload_eds: @@ -157,7 +164,11 @@ def add_node(self, node, object_dictionary=None, upload_eds=False): self[node.id] = node return node - def create_node(self, node, object_dictionary=None): + def create_node( + self, + node: int, + object_dictionary: Union[str, ObjectDictionary, None] = None, + ) -> LocalNode: """Create a local node in the network. :param node: @@ -169,14 +180,13 @@ def create_node(self, node, object_dictionary=None): :return: The Node object that was added. - :rtype: canopen.LocalNode """ if isinstance(node, int): node = LocalNode(node, object_dictionary) self[node.id] = node return node - def send_message(self, can_id, data, remote=False): + def send_message(self, can_id: int, data: bytes, remote: bool = False) -> None: """Send a raw CAN message to the network. This method may be overridden in a subclass if you need to integrate @@ -203,35 +213,36 @@ def send_message(self, can_id, data, remote=False): self.bus.send(msg) self.check() - def send_periodic(self, can_id, data, period, remote=False): + def send_periodic( + self, can_id: int, data: bytes, period: float, remote: bool = False + ) -> "PeriodicMessageTask": """Start sending a message periodically. - :param int can_id: + :param can_id: CAN-ID of the message :param data: Data to be transmitted (anything that can be converted to bytes) - :param float period: + :param period: Seconds between each message - :param bool remote: + :param remote: indicates if the message frame is a remote request to the slave node :return: An task object with a ``.stop()`` method to stop the transmission - :rtype: canopen.network.PeriodicMessageTask """ return PeriodicMessageTask(can_id, data, period, self.bus, remote) - def notify(self, can_id, data, timestamp): + def notify(self, can_id: int, data: bytearray, timestamp: float) -> None: """Feed incoming message to this library. If a custom interface is used, this function must be called for each message read from the CAN bus. - :param int can_id: + :param can_id: CAN-ID of the message - :param bytearray data: + :param data: Data part of the message (0 - 8 bytes) - :param float timestamp: + :param timestamp: Timestamp of the message, preferably as a Unix timestamp """ if can_id in self.subscribers: @@ -240,7 +251,7 @@ def notify(self, can_id, data, timestamp): callback(can_id, data, timestamp) self.scanner.on_message_received(can_id) - def check(self): + def check(self) -> None: """Check that no fatal error has occurred in the receiving thread. If an exception caused the thread to terminate, that exception will be @@ -252,22 +263,22 @@ def check(self): logger.error("An error has caused receiving of messages to stop") raise exc - def __getitem__(self, node_id): + def __getitem__(self, node_id: int) -> Union[RemoteNode, LocalNode]: return self.nodes[node_id] - def __setitem__(self, node_id, node): + def __setitem__(self, node_id: int, node: Union[RemoteNode, LocalNode]): assert node_id == node.id self.nodes[node_id] = node node.associate_network(self) - def __delitem__(self, node_id): + def __delitem__(self, node_id: int): self.nodes[node_id].remove_network() del self.nodes[node_id] - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(self.nodes) - def __len__(self): + def __len__(self) -> int: return len(self.nodes) @@ -277,13 +288,20 @@ class PeriodicMessageTask(object): CyclicSendTask """ - def __init__(self, can_id, data, period, bus, remote=False): - """ - :param int can_id: + def __init__( + self, + can_id: int, + data: bytes, + period: float, + bus, + remote: bool = False, + ): + """ + :param can_id: CAN-ID of the message :param data: Data to be transmitted (anything that can be converted to bytes) - :param float period: + :param period: Seconds between each message :param can.BusABC bus: python-can bus to use for transmission @@ -303,7 +321,7 @@ def stop(self): """Stop transmission""" self._task.stop() - def update(self, data): + def update(self, data: bytes) -> None: """Update data of message :param data: @@ -323,11 +341,11 @@ def update(self, data): class MessageListener(Listener): """Listens for messages on CAN bus and feeds them to a Network instance. - :param canopen.Network network: + :param network: The network to notify on new messages. """ - def __init__(self, network): + def __init__(self, network: Network): self.network = network def on_message_received(self, msg): @@ -359,12 +377,12 @@ class NodeScanner(object): SERVICES = (0x700, 0x580, 0x180, 0x280, 0x380, 0x480, 0x80) - def __init__(self, network=None): + def __init__(self, network: Optional[Network] = None): self.network = network #: A :class:`list` of nodes discovered - self.nodes = [] + self.nodes: List[int] = [] - def on_message_received(self, can_id): + def on_message_received(self, can_id: int): service = can_id & 0x780 node_id = can_id & 0x7F if node_id not in self.nodes and node_id != 0 and service in self.SERVICES: @@ -374,11 +392,10 @@ def reset(self): """Clear list of found nodes.""" self.nodes = [] - def search(self, limit=127): + def search(self, limit: int = 127) -> None: """Search for nodes by sending SDO requests to all node IDs.""" if self.network is None: raise RuntimeError("A Network is required to do active scanning") sdo_req = b"\x40\x00\x10\x00\x00\x00\x00\x00" for node_id in range(1, limit + 1): self.network.send_message(0x600 + node_id, sdo_req) - diff --git a/canopen/nmt.py b/canopen/nmt.py index 60a7f758..09963de0 100644 --- a/canopen/nmt.py +++ b/canopen/nmt.py @@ -2,6 +2,7 @@ import logging import struct import time +from typing import Callable, Optional from .network import CanError @@ -44,7 +45,7 @@ class NmtBase(object): the current state using the heartbeat protocol. """ - def __init__(self, node_id): + def __init__(self, node_id: int): self.id = node_id self.network = None self._state = 0 @@ -60,20 +61,20 @@ def on_command(self, can_id, data, timestamp): NMT_STATES[new_state], NMT_STATES[self._state]) self._state = new_state - def send_command(self, code): + def send_command(self, code: int): """Send an NMT command code to the node. - :param int code: + :param code: NMT command code. """ if code in COMMAND_TO_STATE: new_state = COMMAND_TO_STATE[code] - logger.info("Changing NMT state from %s to %s", - NMT_STATES[self._state], NMT_STATES[new_state]) + logger.info("Changing NMT state on node %d from %s to %s", + self.id, NMT_STATES[self._state], NMT_STATES[new_state]) self._state = new_state @property - def state(self): + def state(self) -> str: """Attribute to get or set node's state as a string. Can be one of: @@ -93,7 +94,7 @@ def state(self): return self._state @state.setter - def state(self, new_state): + def state(self, new_state: str): if new_state in NMT_COMMANDS: code = NMT_COMMANDS[new_state] else: @@ -105,12 +106,12 @@ def state(self, new_state): class NmtMaster(NmtBase): - def __init__(self, node_id): + def __init__(self, node_id: int): super(NmtMaster, self).__init__(node_id) self._state_received = None self._node_guarding_producer = None #: Timestamp of last heartbeat message - self.timestamp = None + self.timestamp: Optional[float] = None self.state_update = threading.Condition() self._callbacks = [] @@ -131,10 +132,10 @@ def on_heartbeat(self, can_id, data, timestamp): self._state_received = new_state self.state_update.notify_all() - def send_command(self, code): + def send_command(self, code: int): """Send an NMT command code to the node. - :param int code: + :param code: NMT command code. """ super(NmtMaster, self).send_command(code) @@ -142,7 +143,7 @@ def send_command(self, code): "Sending NMT command 0x%X to node %d", code, self.id) self.network.send_message(0, [code, self.id]) - def wait_for_heartbeat(self, timeout=10): + def wait_for_heartbeat(self, timeout: float = 10): """Wait until a heartbeat message is received.""" with self.state_update: self._state_received = None @@ -151,7 +152,7 @@ def wait_for_heartbeat(self, timeout=10): raise NmtError("No boot-up or heartbeat received") return self.state - def wait_for_bootup(self, timeout=10): + def wait_for_bootup(self, timeout: float = 10) -> None: """Wait until a boot-up message is received.""" end_time = time.time() + timeout while True: @@ -164,7 +165,7 @@ def wait_for_bootup(self, timeout=10): if self._state_received == 0: break - def add_hearbeat_callback(self, callback): + def add_hearbeat_callback(self, callback: Callable[[int], None]): """Add function to be called on heartbeat reception. :param callback: @@ -172,10 +173,10 @@ def add_hearbeat_callback(self, callback): """ self._callbacks.append(callback) - def start_node_guarding(self, period): + def start_node_guarding(self, period: float): """Starts the node guarding mechanism. - :param float period: + :param period: Period (in seconds) at which the node guarding should be advertised to the slave node. """ if self._node_guarding_producer : self.stop_node_guarding() @@ -193,7 +194,7 @@ class NmtSlave(NmtBase): Handles the NMT state and handles heartbeat NMT service. """ - def __init__(self, node_id, local_node): + def __init__(self, node_id: int, local_node): super(NmtSlave, self).__init__(node_id) self._send_task = None self._heartbeat_time_ms = 0 @@ -203,10 +204,10 @@ def on_command(self, can_id, data, timestamp): super(NmtSlave, self).on_command(can_id, data, timestamp) self.update_heartbeat() - def send_command(self, code): + def send_command(self, code: int) -> None: """Send an NMT command code to the node. - :param int code: + :param code: NMT command code. """ old_state = self._state @@ -232,10 +233,10 @@ def on_write(self, index, data, **kwargs): else: self.start_heartbeat(hearbeat_time) - def start_heartbeat(self, heartbeat_time_ms): + def start_heartbeat(self, heartbeat_time_ms: int): """Start the hearbeat service. - :param int hearbeat_time + :param hearbeat_time The heartbeat time in ms. If the heartbeat time is 0 the heartbeating will not start. """ diff --git a/canopen/node/base.py b/canopen/node/base.py index 9df02e43..d87e5517 100644 --- a/canopen/node/base.py +++ b/canopen/node/base.py @@ -1,18 +1,22 @@ +from typing import TextIO, Union from .. import objectdictionary class BaseNode(object): """A CANopen node. - :param int node_id: + :param node_id: Node ID (set to None or 0 if specified by object dictionary) :param object_dictionary: Object dictionary as either a path to a file, an ``ObjectDictionary`` or a file like object. - :type object_dictionary: :class:`str`, :class:`canopen.ObjectDictionary` """ - def __init__(self, node_id, object_dictionary): + def __init__( + self, + node_id: int, + object_dictionary: Union[objectdictionary.ObjectDictionary, str, TextIO], + ): self.network = None if not isinstance(object_dictionary, diff --git a/canopen/node/local.py b/canopen/node/local.py index 8eee9420..ecce2dff 100644 --- a/canopen/node/local.py +++ b/canopen/node/local.py @@ -1,5 +1,5 @@ import logging -import struct +from typing import Dict, Union from .base import BaseNode from ..sdo import SdoServer, SdoAbortedError @@ -13,10 +13,14 @@ class LocalNode(BaseNode): - def __init__(self, node_id, object_dictionary): + def __init__( + self, + node_id: int, + object_dictionary: Union[objectdictionary.ObjectDictionary, str], + ): super(LocalNode, self).__init__(node_id, object_dictionary) - self.data_store = {} + self.data_store: Dict[int, Dict[int, bytes]] = {} self._read_callbacks = [] self._write_callbacks = [] @@ -55,7 +59,9 @@ def add_read_callback(self, callback): def add_write_callback(self, callback): self._write_callbacks.append(callback) - def get_data(self, index, subindex, check_readable=False): + def get_data( + self, index: int, subindex: int, check_readable: bool = False + ) -> bytes: obj = self._find_object(index, subindex) if check_readable and not obj.readable: @@ -82,7 +88,13 @@ def get_data(self, index, subindex, check_readable=False): logger.info("Resource unavailable for 0x%X:%d", index, subindex) raise SdoAbortedError(0x060A0023) - def set_data(self, index, subindex, data, check_writable=False): + def set_data( + self, + index: int, + subindex: int, + data: bytes, + check_writable: bool = False, + ) -> None: obj = self._find_object(index, subindex) if check_writable and not obj.writable: diff --git a/canopen/node/remote.py b/canopen/node/remote.py index 5a531868..864ffeb3 100644 --- a/canopen/node/remote.py +++ b/canopen/node/remote.py @@ -1,4 +1,5 @@ import logging +from typing import Union, TextIO from ..sdo import SdoClient from ..nmt import NmtMaster @@ -9,24 +10,30 @@ import canopen +from canopen import objectdictionary + logger = logging.getLogger(__name__) class RemoteNode(BaseNode): """A CANopen remote node. - :param int node_id: + :param node_id: Node ID (set to None or 0 if specified by object dictionary) :param object_dictionary: Object dictionary as either a path to a file, an ``ObjectDictionary`` or a file like object. - :param bool load_od: + :param load_od: Enable the Object Dictionary to be sent trough SDO's to the remote node at startup. - :type object_dictionary: :class:`str`, :class:`canopen.ObjectDictionary` """ - def __init__(self, node_id, object_dictionary, load_od=False): + def __init__( + self, + node_id: int, + object_dictionary: Union[objectdictionary.ObjectDictionary, str, TextIO], + load_od: bool = False, + ): super(RemoteNode, self).__init__(node_id, object_dictionary) #: Enable WORKAROUND for reversed PDO mapping entries diff --git a/canopen/objectdictionary/__init__.py b/canopen/objectdictionary/__init__.py index 25a2f37a..f900f71c 100644 --- a/canopen/objectdictionary/__init__.py +++ b/canopen/objectdictionary/__init__.py @@ -2,6 +2,7 @@ Object Dictionary module """ import struct +from typing import Dict, Iterable, List, Optional, TextIO, Union try: from collections.abc import MutableMapping, Mapping except ImportError: @@ -13,7 +14,45 @@ logger = logging.getLogger(__name__) -def import_od(source, node_id=None): +def export_od(od, dest:Union[str,TextIO,None]=None, doc_type:Optional[str]=None): + """ Export :class: ObjectDictionary to a file. + + :param od: + :class: ObjectDictionary object to be exported + :param dest: + export destination. filename, or file-like object or None. + if None, the document is returned as string + :param doc_type: type of document to export. + If a filename is given for dest, this default to the file extension. + Otherwise, this defaults to "eds" + :rtype: str or None + """ + + doctypes = {"eds", "dcf"} + if type(dest) is str: + if doc_type is None: + for t in doctypes: + if dest.endswith(f".{t}"): + doc_type = t + break + + if doc_type is None: + doc_type = "eds" + dest = open(dest, 'w') + assert doc_type in doctypes + + if doc_type == "eds": + from . import eds + return eds.export_eds(od, dest) + elif doc_type == "dcf": + from . import eds + return eds.export_dcf(od, dest) + + +def import_od( + source: Union[str, TextIO, None], + node_id: Optional[int] = None, +) -> "ObjectDictionary": """Parse an EDS, DCF, or EPF file. :param source: @@ -21,7 +60,6 @@ def import_od(source, node_id=None): :return: An Object Dictionary instance. - :rtype: canopen.ObjectDictionary """ if source is None: return ObjectDictionary() @@ -51,12 +89,17 @@ class ObjectDictionary(MutableMapping): def __init__(self): self.indices = {} self.names = {} + self.comments = "" #: Default bitrate if specified by file - self.bitrate = None + self.bitrate: Optional[int] = None #: Node ID if specified by file - self.node_id = None + self.node_id: Optional[int] = None + #: Some information about the device + self.device_information = DeviceInformation() - def __getitem__(self, index): + def __getitem__( + self, index: Union[int, str] + ) -> Union["Array", "Record", "Variable"]: """Get object from object dictionary by name or index.""" item = self.names.get(index) or self.indices.get(index) if item is None: @@ -64,25 +107,27 @@ def __getitem__(self, index): raise KeyError("%s was not found in Object Dictionary" % name) return item - def __setitem__(self, index, obj): + def __setitem__( + self, index: Union[int, str], obj: Union["Array", "Record", "Variable"] + ): assert index == obj.index or index == obj.name self.add_object(obj) - def __delitem__(self, index): + def __delitem__(self, index: Union[int, str]): obj = self[index] del self.indices[obj.index] del self.names[obj.name] - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(sorted(self.indices)) - def __len__(self): + def __len__(self) -> int: return len(self.indices) - def __contains__(self, index): + def __contains__(self, index: Union[int, str]): return index in self.names or index in self.indices - def add_object(self, obj): + def add_object(self, obj: Union["Array", "Record", "Variable"]) -> None: """Add object to the object dictionary. :param obj: @@ -95,11 +140,12 @@ def add_object(self, obj): self.indices[obj.index] = obj self.names[obj.name] = obj - def get_variable(self, index, subindex=0): + def get_variable( + self, index: Union[int, str], subindex: int = 0 + ) -> Optional["Variable"]: """Get the variable object at specified index (and subindex if applicable). :return: Variable if found, else `None` - :rtype: canopen.objectdictionary.Variable """ obj = self.get(index) if isinstance(obj, Variable): @@ -116,9 +162,9 @@ class Record(MutableMapping): #: Description for the whole record description = "" - def __init__(self, name, index): + def __init__(self, name: str, index: int): #: The :class:`~canopen.ObjectDictionary` owning the record. - self.parent = None + self.parent: Optional[ObjectDictionary] = None #: 16-bit address of the record self.index = index #: Name of record @@ -128,34 +174,34 @@ def __init__(self, name, index): self.subindices = {} self.names = {} - def __getitem__(self, subindex): + def __getitem__(self, subindex: Union[int, str]) -> "Variable": item = self.names.get(subindex) or self.subindices.get(subindex) if item is None: raise KeyError("Subindex %s was not found" % subindex) return item - def __setitem__(self, subindex, var): + def __setitem__(self, subindex: Union[int, str], var: "Variable"): assert subindex == var.subindex self.add_member(var) - def __delitem__(self, subindex): + def __delitem__(self, subindex: Union[int, str]): var = self[subindex] del self.subindices[var.subindex] del self.names[var.name] - def __len__(self): + def __len__(self) -> int: return len(self.subindices) - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(sorted(self.subindices)) - def __contains__(self, subindex): + def __contains__(self, subindex: Union[int, str]) -> bool: return subindex in self.names or subindex in self.subindices - def __eq__(self, other): + def __eq__(self, other: "Record") -> bool: return self.index == other.index - def add_member(self, variable): + def add_member(self, variable: "Variable") -> None: """Adds a :class:`~canopen.objectdictionary.Variable` to the record.""" variable.parent = self self.subindices[variable.subindex] = variable @@ -172,7 +218,7 @@ class Array(Mapping): #: Description for the whole array description = "" - def __init__(self, name, index): + def __init__(self, name: str, index: int): #: The :class:`~canopen.ObjectDictionary` owning the record. self.parent = None #: 16-bit address of the array @@ -184,7 +230,7 @@ def __init__(self, name, index): self.subindices = {} self.names = {} - def __getitem__(self, subindex): + def __getitem__(self, subindex: Union[int, str]) -> "Variable": var = self.names.get(subindex) or self.subindices.get(subindex) if var is not None: # This subindex is defined @@ -204,16 +250,16 @@ def __getitem__(self, subindex): raise KeyError("Could not find subindex %r" % subindex) return var - def __len__(self): + def __len__(self) -> int: return len(self.subindices) - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(sorted(self.subindices)) - def __eq__(self, other): + def __eq__(self, other: "Array") -> bool: return self.index == other.index - def add_member(self, variable): + def add_member(self, variable: "Variable") -> None: """Adds a :class:`~canopen.objectdictionary.Variable` to the record.""" variable.parent = self self.subindices[variable.subindex] = variable @@ -237,7 +283,7 @@ class Variable(object): REAL64: struct.Struct(" bool: return (self.index == other.index and self.subindex == other.subindex) - def __len__(self): + def __len__(self) -> int: if self.data_type in self.STRUCT_TYPES: return self.STRUCT_TYPES[self.data_type].size * 8 else: return 8 @property - def writable(self): + def writable(self) -> bool: return "w" in self.access_type @property - def readable(self): + def readable(self) -> bool: return "r" in self.access_type or self.access_type == "const" - def add_value_description(self, value, descr): + def add_value_description(self, value: int, descr: str) -> None: """Associate a value with a string description. - :param int value: Value to describe - :param str desc: Description of value + :param value: Value to describe + :param desc: Description of value """ self.value_descriptions[value] = descr - def add_bit_definition(self, name, bits): + def add_bit_definition(self, name: str, bits: List[int]) -> None: """Associate bit(s) with a string description. - :param str name: Name of bit(s) - :param list bits: List of bits as integers + :param name: Name of bit(s) + :param bits: List of bits as integers """ self.bit_definitions[name] = bits - def decode_raw(self, data): + def decode_raw(self, data: bytes) -> Union[int, float, str, bytes, bytearray]: if self.data_type == VISIBLE_STRING: return data.rstrip(b"\x00").decode("ascii", errors="ignore") elif self.data_type == UNICODE_STRING: @@ -324,7 +375,7 @@ def decode_raw(self, data): # Just return the data as is return data - def encode_raw(self, value): + def encode_raw(self, value: Union[int, float, str, bytes, bytearray]) -> bytes: if isinstance(value, (bytes, bytearray)): return value elif self.data_type == VISIBLE_STRING: @@ -355,18 +406,18 @@ def encode_raw(self, value): "Do not know how to encode %r to data type %Xh" % ( value, self.data_type)) - def decode_phys(self, value): + def decode_phys(self, value: int) -> Union[int, bool, float, str, bytes]: if self.data_type in INTEGER_TYPES: value *= self.factor return value - def encode_phys(self, value): + def encode_phys(self, value: Union[int, bool, float, str, bytes]) -> int: if self.data_type in INTEGER_TYPES: value /= self.factor value = int(round(value)) return value - def decode_desc(self, value): + def decode_desc(self, value: int) -> str: if not self.value_descriptions: raise ObjectDictionaryError("No value descriptions exist") elif value not in self.value_descriptions: @@ -375,7 +426,7 @@ def decode_desc(self, value): else: return self.value_descriptions[value] - def encode_desc(self, desc): + def encode_desc(self, desc: str) -> int: if not self.value_descriptions: raise ObjectDictionaryError("No value descriptions exist") else: @@ -386,7 +437,7 @@ def encode_desc(self, desc): error_text = "No value corresponds to '%s'. Valid values are: %s" raise ValueError(error_text % (desc, valid_values)) - def decode_bits(self, value, bits): + def decode_bits(self, value: int, bits: List[int]) -> int: try: bits = self.bit_definitions[bits] except (TypeError, KeyError): @@ -396,7 +447,7 @@ def decode_bits(self, value, bits): mask |= 1 << bit return (value & mask) >> min(bits) - def encode_bits(self, original_value, bits, bit_value): + def encode_bits(self, original_value: int, bits: List[int], bit_value: int): try: bits = self.bit_definitions[bits] except (TypeError, KeyError): @@ -410,5 +461,24 @@ def encode_bits(self, original_value, bits, bit_value): return temp +class DeviceInformation: + def __init__(self): + self.allowed_baudrates = set() + self.vendor_name:Optional[str] = None + self.vendor_number:Optional[int] = None + self.product_name:Optional[str] = None + self.product_number:Optional[int] = None + self.revision_number:Optional[int] = None + self.order_code:Optional[str] = None + self.simple_boot_up_master:Optional[bool] = None + self.simple_boot_up_slave:Optional[bool] = None + self.granularity:Optional[int] = None + self.dynamic_channels_supported:Optional[bool] = None + self.group_messaging:Optional[bool] = None + self.nr_of_RXPDO:Optional[bool] = None + self.nr_of_TXPDO:Optional[bool] = None + self.LSS_supported:Optional[bool] = None + + class ObjectDictionaryError(Exception): """Unsupported operation with the current Object Dictionary.""" diff --git a/canopen/objectdictionary/eds.py b/canopen/objectdictionary/eds.py index 81c38adc..afd94159 100644 --- a/canopen/objectdictionary/eds.py +++ b/canopen/objectdictionary/eds.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) +# Object type. Don't confuse with Data type DOMAIN = 2 VAR = 7 ARR = 8 @@ -19,6 +20,7 @@ def import_eds(source, node_id): eds = RawConfigParser() + eds.optionxform = str if hasattr(source, "read"): fp = source else: @@ -31,6 +33,57 @@ def import_eds(source, node_id): eds.readfp(fp) fp.close() od = objectdictionary.ObjectDictionary() + + if eds.has_section("FileInfo"): + od.__edsFileInfo = { + opt: eds.get("FileInfo", opt) + for opt in eds.options("FileInfo") + } + + if eds.has_section("Comments"): + linecount = int(eds.get("Comments", "Lines"), 0) + od.comments = '\n'.join([ + eds.get("Comments", "Line%i" % line) + for line in range(1, linecount+1) + ]) + + if not eds.has_section("DeviceInfo"): + logger.warn("eds file does not have a DeviceInfo section. This section is mandatory") + else: + for rate in [10, 20, 50, 125, 250, 500, 800, 1000]: + baudPossible = int( + eds.get("DeviceInfo", "Baudrate_%i" % rate, fallback='0'), 0) + if baudPossible != 0: + od.device_information.allowed_baudrates.add(rate*1000) + + for t, eprop, odprop in [ + (str, "VendorName", "vendor_name"), + (int, "VendorNumber", "vendor_number"), + (str, "ProductName", "product_name"), + (int, "ProductNumber", "product_number"), + (int, "RevisionNumber", "revision_number"), + (str, "OrderCode", "order_code"), + (bool, "SimpleBootUpMaster", "simple_boot_up_master"), + (bool, "SimpleBootUpSlave", "simple_boot_up_slave"), + (bool, "Granularity", "granularity"), + (bool, "DynamicChannelsSupported", "dynamic_channels_supported"), + (bool, "GroupMessaging", "group_messaging"), + (int, "NrOfRXPDO", "nr_of_RXPDO"), + (int, "NrOfTXPDO", "nr_of_TXPDO"), + (bool, "LSS_Supported", "LSS_supported"), + ]: + try: + if t in (int, bool): + setattr(od.device_information, odprop, + t(int(eds.get("DeviceInfo", eprop), 0)) + ) + elif t is str: + setattr(od.device_information, odprop, + eds.get("DeviceInfo", eprop) + ) + except NoOptionError: + pass + if eds.has_section("DeviceComissioning"): od.bitrate = int(eds.get("DeviceComissioning", "Baudrate")) * 1000 od.node_id = int(eds.get("DeviceComissioning", "NodeID"), 0) @@ -146,13 +199,26 @@ def _convert_variable(node_id, var_type, value): return float(value) else: # COB-ID can contain '$NODEID+' so replace this with node_id before converting - value = value.replace(" ","").upper() + value = value.replace(" ", "").upper() if '$NODEID' in value and node_id is not None: return int(re.sub(r'\+?\$NODEID\+?', '', value), 0) + node_id else: return int(value, 0) +def _revert_variable(var_type, value): + if value is None: + return None + if var_type in (objectdictionary.OCTET_STRING, objectdictionary.DOMAIN): + return bytes.hex(value) + elif var_type in (objectdictionary.VISIBLE_STRING, objectdictionary.UNICODE_STRING): + return value + elif var_type in objectdictionary.FLOAT_TYPES: + return value + else: + return "0x%02X" % value + + def build_variable(eds, section, node_id, index, subindex=0): """Creates a object dictionary entry. :param eds: String stream of the eds file @@ -181,6 +247,8 @@ def build_variable(eds, section, node_id, index, subindex=0): # Assume DOMAIN to force application to interpret the byte data var.data_type = objectdictionary.DOMAIN + var.pdo_mappable = bool(int(eds.get(section, "PDOMapping", fallback="0"), 0)) + if eds.has_option(section, "LowLimit"): try: var.min = int(eds.get(section, "LowLimit"), 0) @@ -193,11 +261,15 @@ def build_variable(eds, section, node_id, index, subindex=0): pass if eds.has_option(section, "DefaultValue"): try: + var.default_raw = eds.get(section, "DefaultValue") + if '$NODEID' in var.default_raw: + var.relative = True var.default = _convert_variable(node_id, var.data_type, eds.get(section, "DefaultValue")) except ValueError: pass if eds.has_option(section, "ParameterValue"): try: + var.value_raw = eds.get(section, "ParameterValue") var.value = _convert_variable(node_id, var.data_type, eds.get(section, "ParameterValue")) except ValueError: pass @@ -211,3 +283,183 @@ def copy_variable(eds, section, subindex, src_var): var.name = name var.subindex = subindex return var + + +def export_dcf(od, dest=None, fileInfo={}): + return export_eds(od, dest, fileInfo, True) + + +def export_eds(od, dest=None, file_info={}, device_commisioning=False): + def export_object(obj, eds): + if type(obj) is objectdictionary.Variable: + return export_variable(obj, eds) + if type(obj) is objectdictionary.Record: + return export_record(obj, eds) + if type(obj) is objectdictionary.Array: + return export_array(obj, eds) + + def export_common(var, eds, section): + eds.add_section(section) + eds.set(section, "ParameterName", var.name) + if var.storage_location: + eds.set(section, "StorageLocation", var.storage_location) + + def export_variable(var, eds): + if type(var.parent) is objectdictionary.ObjectDictionary: + # top level variable + section = "%04X" % var.index + else: + # nested variable + section = "%04Xsub%X" % (var.index, var.subindex) + + export_common(var, eds, section) + eds.set(section, "ObjectType", "0x%X" % VAR) + if var.data_type: + eds.set(section, "DataType", "0x%04X" % var.data_type) + if var.access_type: + eds.set(section, "AccessType", var.access_type) + + if getattr(var, 'default_raw', None) is not None: + eds.set(section, "DefaultValue", var.default_raw) + elif getattr(var, 'default', None) is not None: + eds.set(section, "DefaultValue", _revert_variable( + var.data_type, var.default)) + + if device_commisioning: + if getattr(var, 'value_raw', None) is not None: + eds.set(section, "ParameterValue", var.value_raw) + elif getattr(var, 'value', None) is not None: + eds.set(section, "ParameterValue", + _revert_variable(var.data_type, var.default)) + + eds.set(section, "DataType", "0x%04X" % var.data_type) + eds.set(section, "PDOMapping", hex(var.pdo_mappable)) + + if getattr(var, 'min', None) is not None: + eds.set(section, "LowLimit", var.min) + if getattr(var, 'max', None) is not None: + eds.set(section, "HighLimit", var.max) + + def export_record(var, eds): + section = "%04X" % var.index + export_common(var, eds, section) + eds.set(section, "SubNumber", "0x%X" % len(var.subindices)) + ot = RECORD if type(var) is objectdictionary.Record else ARR + eds.set(section, "ObjectType", "0x%X" % ot) + for i in var: + export_variable(var[i], eds) + + export_array = export_record + + eds = RawConfigParser() + # both disables lowercasing, and allows int keys + eds.optionxform = str + + from datetime import datetime as dt + defmtime = dt.utcnow() + + try: + # only if eds was loaded by us + origFileInfo = od.__edsFileInfo + except AttributeError: + origFileInfo = { + # just set some defaults + "CreationDate": defmtime.strftime("%m-%d-%Y"), + "CreationTime": defmtime.strftime("%I:%m%p"), + "EdsVersion": 4.2, + } + + file_info.setdefault("ModificationDate", defmtime.strftime("%m-%d-%Y")) + file_info.setdefault("ModificationTime", defmtime.strftime("%I:%m%p")) + for k, v in origFileInfo.items(): + file_info.setdefault(k, v) + + eds.add_section("FileInfo") + for k, v in file_info.items(): + eds.set("FileInfo", k, v) + + eds.add_section("DeviceInfo") + for eprop, odprop in [ + ("VendorName", "vendor_name"), + ("VendorNumber", "vendor_number"), + ("ProductName", "product_name"), + ("ProductNumber", "product_number"), + ("RevisionNumber", "revision_number"), + ("OrderCode", "order_code"), + ("SimpleBootUpMaster", "simple_boot_up_master"), + ("SimpleBootUpSlave", "simple_boot_up_slave"), + ("Granularity", "granularity"), + ("DynamicChannelsSupported", "dynamic_channels_supported"), + ("GroupMessaging", "group_messaging"), + ("NrOfRXPDO", "nr_of_RXPDO"), + ("NrOfTXPDO", "nr_of_TXPDO"), + ("LSS_Supported", "LSS_supported"), + ]: + val = getattr(od.device_information, odprop, None) + if type(val) is None: + continue + elif type(val) is str: + eds.set("DeviceInfo", eprop, val) + elif type(val) in (int, bool): + eds.set("DeviceInfo", eprop, int(val)) + + # we are also adding out of spec baudrates here. + for rate in od.device_information.allowed_baudrates.union( + {10e3, 20e3, 50e3, 125e3, 250e3, 500e3, 800e3, 1000e3}): + eds.set( + "DeviceInfo", "Baudrate_%i" % (rate/1000), + int(rate in od.device_information.allowed_baudrates)) + + if device_commisioning and (od.bitrate or od.node_id): + eds.add_section("DeviceComissioning") + if od.bitrate: + eds.set("DeviceComissioning", "Baudrate", int(od.bitrate / 1000)) + if od.node_id: + eds.set("DeviceComissioning", "NodeID", int(od.node_id)) + + eds.add_section("Comments") + i = 0 + for line in od.comments.splitlines(): + i += 1 + eds.set("Comments", "Line%i" % i, line) + eds.set("Comments", "Lines", i) + + eds.add_section("DummyUsage") + for i in range(1, 8): + key = "Dummy%04d" % i + eds.set("DummyUsage", key, 1 if (key in od) else 0) + + def mandatory_indices(x): + return x in {0x1000, 0x1001, 0x1018} + + def manufacturer_idices(x): + return x in range(0x2000, 0x6000) + + def optional_indices(x): + return all(( + x > 0x1001, + not mandatory_indices(x), + not manufacturer_idices(x), + )) + + supported_mantatory_indices = list(filter(mandatory_indices, od)) + supported_optional_indices = list(filter(optional_indices, od)) + supported_manufacturer_indices = list(filter(manufacturer_idices, od)) + + def add_list(section, list): + eds.add_section(section) + eds.set(section, "SupportedObjects", len(list)) + for i in range(0, len(list)): + eds.set(section, (i + 1), "0x%04X" % list[i]) + for index in list: + export_object(od[index], eds) + + add_list("MandatoryObjects", supported_mantatory_indices) + add_list("OptionalObjects", supported_optional_indices) + add_list("ManufacturerObjects", supported_manufacturer_indices) + + if not dest: + import sys + dest = sys.stdout + + eds.write(dest, False) diff --git a/canopen/pdo/__init__.py b/canopen/pdo/__init__.py index 5d8a3ba4..a08b3ccc 100644 --- a/canopen/pdo/__init__.py +++ b/canopen/pdo/__init__.py @@ -8,7 +8,8 @@ class PDO(PdoBase): - """PDO Class for backwards compatibility + """PDO Class for backwards compatibility. + :param rpdo: RPDO object holding the Receive PDO mappings :param tpdo: TPDO object holding the Transmit PDO mappings """ @@ -27,9 +28,11 @@ def __init__(self, node, rpdo, tpdo): class RPDO(PdoBase): - """PDO specialization for the Receive PDO enabling the transfer of data from the master to the node. + """Receive PDO to transfer data from somewhere to the represented node. + Properties 0x1400 to 0x1403 | Mapping 0x1600 to 0x1603. - :param object node: Parent node for this object.""" + :param object node: Parent node for this object. + """ def __init__(self, node): super(RPDO, self).__init__(node) @@ -38,8 +41,10 @@ def __init__(self, node): def stop(self): """Stop transmission of all RPDOs. + :raise TypeError: Exception is thrown if the node associated with the PDO does not - support this function""" + support this function. + """ if isinstance(self.node, canopen.RemoteNode): for pdo in self.map.values(): pdo.stop() @@ -48,8 +53,11 @@ def stop(self): class TPDO(PdoBase): - """PDO specialization for the Transmit PDO enabling the transfer of data from the node to the master. - Properties 0x1800 to 0x1803 | Mapping 0x1A00 to 0x1A03.""" + """Transmit PDO to broadcast data from the represented node to the network. + + Properties 0x1800 to 0x1803 | Mapping 0x1A00 to 0x1A03. + :param object node: Parent node for this object. + """ def __init__(self, node): super(TPDO, self).__init__(node) @@ -58,8 +66,10 @@ def __init__(self, node): def stop(self): """Stop transmission of all TPDOs. + :raise TypeError: Exception is thrown if the node associated with the PDO does not - support this function""" + support this function. + """ if isinstance(canopen.LocalNode, self.node): for pdo in self.map.values(): pdo.stop() diff --git a/canopen/pdo/base.py b/canopen/pdo/base.py index 4fca9ffc..1685de62 100644 --- a/canopen/pdo/base.py +++ b/canopen/pdo/base.py @@ -1,5 +1,6 @@ import threading import math +from typing import Callable, Dict, Iterable, List, Optional, Union try: from collections.abc import Mapping except ImportError: @@ -59,6 +60,17 @@ def save(self): for pdo_map in self.map.values(): pdo_map.save() + def subscribe(self): + """Register the node's PDOs for reception on the network. + + This normally happens when the PDO configuration is read from + or saved to the node. Use this method to avoid the SDO flood + associated with read() or save(), if the local PDO setup is + known to match what's stored on the node. + """ + for pdo_map in self.map.values(): + pdo_map.subscribe() + def export(self, filename): """Export current configuration to a database file. @@ -76,8 +88,7 @@ def export(self, filename): if pdo_map.cob_id is None: continue frame = canmatrix.Frame(pdo_map.name, - Id=pdo_map.cob_id, - extended=0) + arbitration_id=pdo_map.cob_id) for var in pdo_map.map: is_signed = var.od.data_type in objectdictionary.SIGNED_TYPES is_float = var.od.data_type in objectdictionary.FLOAT_TYPES @@ -91,8 +102,8 @@ def export(self, filename): name = name.replace(" ", "_") name = name.replace(".", "_") signal = canmatrix.Signal(name, - startBit=var.offset, - signalSize=var.length, + start_bit=var.offset, + size=var.length, is_signed=is_signed, is_float=is_float, factor=var.od.factor, @@ -101,9 +112,9 @@ def export(self, filename): unit=var.od.unit) for value, desc in var.od.value_descriptions.items(): signal.addValues(value, desc) - frame.addSignal(signal) - frame.calcDLC() - db.frames.addFrame(frame) + frame.add_signal(signal) + frame.calc_dlc() + db.add_frame(frame) formats.dumpp({"": db}, filename) return db @@ -116,14 +127,14 @@ def stop(self): class Maps(Mapping): """A collection of transmit or receive maps.""" - def __init__(self, com_offset, map_offset, pdo_node, cob_base=None): + def __init__(self, com_offset, map_offset, pdo_node: PdoBase, cob_base=None): """ :param com_offset: :param map_offset: :param pdo_node: :param cob_base: """ - self.maps = {} + self.maps: Dict[int, "Map"] = {} for map_no in range(512): if com_offset + map_no in pdo_node.node.object_dictionary: new_map = Map( @@ -135,13 +146,13 @@ def __init__(self, com_offset, map_offset, pdo_node, cob_base=None): new_map.predefined_cob_id = cob_base + map_no * 0x100 + pdo_node.node.id self.maps[map_no + 1] = new_map - def __getitem__(self, key): + def __getitem__(self, key: int) -> "Map": return self.maps[key] - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(self.maps) - def __len__(self): + def __len__(self) -> int: return len(self.maps) @@ -153,33 +164,34 @@ def __init__(self, pdo_node, com_record, map_array): self.com_record = com_record self.map_array = map_array #: If this map is valid - self.enabled = False + self.enabled: bool = False #: COB-ID for this PDO - self.cob_id = None + self.cob_id: Optional[int] = None #: Default COB-ID if this PDO is part of the pre-defined connection set - self.predefined_cob_id = None + self.predefined_cob_id: Optional[int] = None #: Is the remote transmit request (RTR) allowed for this PDO - self.rtr_allowed = True + self.rtr_allowed: bool = True #: Transmission type (0-255) - self.trans_type = None + self.trans_type: Optional[int] = None #: Inhibit Time (optional) (in 100us) - self.inhibit_time = None + self.inhibit_time: Optional[int] = None #: Event timer (optional) (in ms) - self.event_timer = None + self.event_timer: Optional[int] = None #: Ignores SYNC objects up to this SYNC counter value (optional) - self.sync_start_value = None + self.sync_start_value: Optional[int] = None #: List of variables mapped to this PDO - self.map = [] - self.length = 0 + self.map: List["Variable"] = [] + self.length: int = 0 #: Current message data self.data = bytearray() #: Timestamp of last received message - self.timestamp = None - #: Period of receive message transmission in seconds - self.period = None + self.timestamp: Optional[float] = None + #: Period of receive message transmission in seconds. + #: Set explicitly or using the :meth:`start()` method. + self.period: Optional[float] = None self.callbacks = [] self.receive_condition = threading.Condition() - self.is_received = False + self.is_received: bool = False self._task = None def __getitem_by_index(self, value): @@ -202,7 +214,7 @@ def __getitem_by_name(self, value): raise KeyError('{0} not found in map. Valid entries are {1}'.format( value, ', '.join(valid_values))) - def __getitem__(self, key): + def __getitem__(self, key: Union[int, str]) -> "Variable": var = None if isinstance(key, int): # there is a maximum available of 8 slots per PDO map @@ -217,10 +229,10 @@ def __getitem__(self, key): var = self.__getitem_by_name(key) return var - def __iter__(self): + def __iter__(self) -> Iterable["Variable"]: return iter(self.map) - def __len__(self): + def __len__(self) -> int: return len(self.map) def _get_variable(self, index, subindex): @@ -245,7 +257,7 @@ def _update_data_size(self): self.data = bytearray(int(math.ceil(self.length / 8.0))) @property - def name(self): + def name(self) -> str: """A descriptive name of the PDO. Examples: @@ -262,6 +274,23 @@ def name(self): node_id = self.cob_id & 0x7F return "%sPDO%d_node%d" % (direction, map_id, node_id) + @property + def is_periodic(self) -> bool: + """Indicate whether PDO updates will be transferred regularly. + + If some external mechanism is used to transmit the PDO regularly, its cycle time + should be written to the :attr:`period` member for this property to work. + """ + if self.period is not None: + # Configured from start() or externally + return True + elif self.trans_type is not None and self.trans_type <= 0xF0: + # TPDOs will be transmitted on SYNC, RPDOs need a SYNC to apply, so + # assume that the SYNC service is active. + return True + # Unknown transmission type, assume non-periodic + return False + def on_message(self, can_id, data, timestamp): is_transmitting = self._task is not None if can_id == self.cob_id and not is_transmitting: @@ -275,7 +304,7 @@ def on_message(self, can_id, data, timestamp): for callback in self.callbacks: callback(self) - def add_callback(self, callback): + def add_callback(self, callback: Callable[["Map"], None]) -> None: """Add a callback which will be called on receive. :param callback: @@ -284,7 +313,7 @@ def add_callback(self, callback): """ self.callbacks.append(callback) - def read(self): + def read(self) -> None: """Read PDO configuration for this map using SDO.""" cob_id = self.com_record[1].raw self.cob_id = cob_id & 0x1FFFFFFF @@ -331,14 +360,13 @@ def read(self): if index and size: self.add_variable(index, subindex, size) - if self.enabled: - self.pdo_node.network.subscribe(self.cob_id, self.on_message) + self.subscribe() - def save(self): + def save(self) -> None: """Save PDO configuration for this map using SDO.""" logger.info("Setting COB-ID 0x%X and temporarily disabling PDO", self.cob_id) - self.com_record[1].raw = self.cob_id | PDO_NOT_VALID + self.com_record[1].raw = self.cob_id | PDO_NOT_VALID | (RTR_NOT_ALLOWED if not self.rtr_allowed else 0x0) if self.trans_type is not None: logger.info("Setting transmission type to %d", self.trans_type) self.com_record[2].raw = self.trans_type @@ -387,25 +415,38 @@ def save(self): self._update_data_size() if self.enabled: - logger.info("Enabling PDO") - self.com_record[1].raw = self.cob_id + self.com_record[1].raw = self.cob_id | (RTR_NOT_ALLOWED if not self.rtr_allowed else 0x0) + self.subscribe() + + def subscribe(self) -> None: + """Register the PDO for reception on the network. + + This normally happens when the PDO configuration is read from + or saved to the node. Use this method to avoid the SDO flood + associated with read() or save(), if the local PDO setup is + known to match what's stored on the node. + """ + if self.enabled: + logger.info("Subscribing to enabled PDO 0x%X on the network", self.cob_id) self.pdo_node.network.subscribe(self.cob_id, self.on_message) - def clear(self): + def clear(self) -> None: """Clear all variables from this map.""" self.map = [] self.length = 0 - def add_variable(self, index, subindex=0, length=None): + def add_variable( + self, + index: Union[str, int], + subindex: Union[str, int] = 0, + length: Optional[int] = None, + ) -> "Variable": """Add a variable from object dictionary as the next entry. :param index: Index of variable as name or number :param subindex: Sub-index of variable as name or number - :param int length: Size of data in number of bits - :type index: :class:`str` or :class:`int` - :type subindex: :class:`str` or :class:`int` + :param length: Size of data in number of bits :return: Variable that was added - :rtype: canopen.pdo.Variable """ try: var = self._get_variable(index, subindex) @@ -431,15 +472,22 @@ def add_variable(self, index, subindex=0, length=None): logger.warning("Max size of PDO exceeded (%d > 64)", self.length) return var - def transmit(self): + def transmit(self) -> None: """Transmit the message once.""" self.pdo_node.network.send_message(self.cob_id, self.data) - def start(self, period=None): + def start(self, period: Optional[float] = None) -> None: """Start periodic transmission of message in a background thread. - :param float period: Transmission period in seconds + :param period: + Transmission period in seconds. Can be omitted if :attr:`period` has been set + on the object before. + :raises ValueError: When neither the argument nor the :attr:`period` is given. """ + # Stop an already running transmission if we have one, otherwise we + # overwrite the reference and can lose our handle to shut it down + self.stop() + if period is not None: self.period = period @@ -450,30 +498,29 @@ def start(self, period=None): self._task = self.pdo_node.network.send_periodic( self.cob_id, self.data, self.period) - def stop(self): + def stop(self) -> None: """Stop transmission.""" if self._task is not None: self._task.stop() self._task = None - def update(self): + def update(self) -> None: """Update periodic message with new data.""" if self._task is not None: self._task.update(self.data) - def remote_request(self): + def remote_request(self) -> None: """Send a remote request for the transmit PDO. Silently ignore if not allowed. """ if self.enabled and self.rtr_allowed: self.pdo_node.network.send_message(self.cob_id, None, remote=True) - def wait_for_reception(self, timeout=10): + def wait_for_reception(self, timeout: float = 10) -> float: """Wait for the next transmit PDO. :param float timeout: Max time to wait in seconds. :return: Timestamp of message received or None if timeout. - :rtype: float """ with self.receive_condition: self.is_received = False @@ -484,7 +531,7 @@ def wait_for_reception(self, timeout=10): class Variable(variable.Variable): """One object dictionary variable mapped to a PDO.""" - def __init__(self, od): + def __init__(self, od: objectdictionary.Variable): #: PDO object that is associated with this Variable Object self.pdo_parent = None #: Location of variable in the message in bits @@ -492,11 +539,10 @@ def __init__(self, od): self.length = len(od) variable.Variable.__init__(self, od) - def get_data(self): + def get_data(self) -> bytes: """Reads the PDO variable from the last received message. :return: Variable value as :class:`bytes`. - :rtype: bytes """ byte_offset, bit_offset = divmod(self.offset, 8) @@ -520,10 +566,10 @@ def get_data(self): return data - def set_data(self, data): + def set_data(self, data: bytes): """Set for the given variable the PDO data. - :param bytes data: Value for the PDO variable in the PDO message as :class:`bytes`. + :param data: Value for the PDO variable in the PDO message. """ byte_offset, bit_offset = divmod(self.offset, 8) logger.debug("Updating %s to %s in %s", diff --git a/canopen/profiles/p402.py b/canopen/profiles/p402.py index f4dedb4f..12ccdd3b 100644 --- a/canopen/profiles/p402.py +++ b/canopen/profiles/p402.py @@ -6,53 +6,54 @@ logger = logging.getLogger(__name__) + class State402(object): # Controlword (0x6040) commands - CW_OPERATION_ENABLED = 0x0F - CW_SHUTDOWN = 0x06 - CW_SWITCH_ON = 0x07 - CW_QUICK_STOP = 0x02 - CW_DISABLE_VOLTAGE = 0x00 - CW_SWITCH_ON_DISABLED = 0x80 + CW_OPERATION_ENABLED = 0x000F + CW_SHUTDOWN = 0x0006 + CW_SWITCH_ON = 0x0007 + CW_QUICK_STOP = 0x0002 + CW_DISABLE_VOLTAGE = 0x0000 + CW_SWITCH_ON_DISABLED = 0x0080 CW_CODE_COMMANDS = { - CW_SWITCH_ON_DISABLED : 'SWITCH ON DISABLED', - CW_DISABLE_VOLTAGE : 'DISABLE VOLTAGE', - CW_SHUTDOWN : 'READY TO SWITCH ON', - CW_SWITCH_ON : 'SWITCHED ON', - CW_OPERATION_ENABLED : 'OPERATION ENABLED', - CW_QUICK_STOP : 'QUICK STOP ACTIVE' + CW_SWITCH_ON_DISABLED: 'SWITCH ON DISABLED', + CW_DISABLE_VOLTAGE: 'DISABLE VOLTAGE', + CW_SHUTDOWN: 'READY TO SWITCH ON', + CW_SWITCH_ON: 'SWITCHED ON', + CW_OPERATION_ENABLED: 'OPERATION ENABLED', + CW_QUICK_STOP: 'QUICK STOP ACTIVE', } CW_COMMANDS_CODE = { - 'SWITCH ON DISABLED' : CW_SWITCH_ON_DISABLED, - 'DISABLE VOLTAGE' : CW_DISABLE_VOLTAGE, - 'READY TO SWITCH ON' : CW_SHUTDOWN, - 'SWITCHED ON' : CW_SWITCH_ON, - 'OPERATION ENABLED' : CW_OPERATION_ENABLED, - 'QUICK STOP ACTIVE' : CW_QUICK_STOP + 'SWITCH ON DISABLED': CW_SWITCH_ON_DISABLED, + 'DISABLE VOLTAGE': CW_DISABLE_VOLTAGE, + 'READY TO SWITCH ON': CW_SHUTDOWN, + 'SWITCHED ON': CW_SWITCH_ON, + 'OPERATION ENABLED': CW_OPERATION_ENABLED, + 'QUICK STOP ACTIVE': CW_QUICK_STOP, } # Statusword 0x6041 bitmask and values in the list in the dictionary value SW_MASK = { - 'NOT READY TO SWITCH ON': [0x4F, 0x00], - 'SWITCH ON DISABLED' : [0x4F, 0x40], - 'READY TO SWITCH ON' : [0x6F, 0x21], - 'SWITCHED ON' : [0x6F, 0x23], - 'OPERATION ENABLED' : [0x6F, 0x27], - 'FAULT' : [0x4F, 0x08], - 'FAULT REACTION ACTIVE' : [0x4F, 0x0F], - 'QUICK STOP ACTIVE' : [0x6F, 0x07] + 'NOT READY TO SWITCH ON': (0x4F, 0x00), + 'SWITCH ON DISABLED': (0x4F, 0x40), + 'READY TO SWITCH ON': (0x6F, 0x21), + 'SWITCHED ON': (0x6F, 0x23), + 'OPERATION ENABLED': (0x6F, 0x27), + 'FAULT': (0x4F, 0x08), + 'FAULT REACTION ACTIVE': (0x4F, 0x0F), + 'QUICK STOP ACTIVE': (0x6F, 0x07), } - # Transition path to get to the 'OPERATION ENABLED' state - NEXTSTATE2ENABLE = { - ('START') : 'NOT READY TO SWITCH ON', - ('FAULT', 'NOT READY TO SWITCH ON') : 'SWITCH ON DISABLED', - ('SWITCH ON DISABLED') : 'READY TO SWITCH ON', - ('READY TO SWITCH ON') : 'SWITCHED ON', - ('SWITCHED ON', 'QUICK STOP ACTIVE', 'OPERATION ENABLED') : 'OPERATION ENABLED', - ('FAULT REACTION ACTIVE') : 'FAULT' + # Transition path to reach and state without a direct transition + NEXTSTATE2ANY = { + ('START'): 'NOT READY TO SWITCH ON', + ('FAULT', 'NOT READY TO SWITCH ON', 'QUICK STOP ACTIVE'): 'SWITCH ON DISABLED', + ('SWITCH ON DISABLED'): 'READY TO SWITCH ON', + ('READY TO SWITCH ON'): 'SWITCHED ON', + ('SWITCHED ON'): 'OPERATION ENABLED', + ('FAULT REACTION ACTIVE'): 'FAULT', } # Tansition table from the DS402 State Machine @@ -77,20 +78,25 @@ class State402(object): ('SWITCHED ON', 'OPERATION ENABLED'): CW_OPERATION_ENABLED, # transition 4 ('QUICK STOP ACTIVE', 'OPERATION ENABLED'): CW_OPERATION_ENABLED, # transition 16 # quickstop --------------------------------------------------------------------------- - ('READY TO SWITCH ON', 'QUICK STOP ACTIVE'): CW_QUICK_STOP, # transition 7 - ('SWITCHED ON', 'QUICK STOP ACTIVE'): CW_QUICK_STOP, # transition 10 ('OPERATION ENABLED', 'QUICK STOP ACTIVE'): CW_QUICK_STOP, # transition 11 # fault ------------------------------------------------------------------------------- ('FAULT', 'SWITCH ON DISABLED'): CW_SWITCH_ON_DISABLED, # transition 15 } @staticmethod - def next_state_for_enabling(_from): - """Returns the next state needed for reach the state Operation Enabled - :param string target: Target state - :return string: Next target to chagne + def next_state_indirect(_from): + """Return the next state needed to reach any state indirectly. + + The chosen path always points toward the OPERATION ENABLED state, except when + coming from QUICK STOP ACTIVE. In that case, it will cycle through SWITCH ON + DISABLED first, as there would have been a direct transition if the opposite was + desired. + + :param str target: Target state. + :return: Next target to change. + :rtype: str """ - for cond, next_state in State402.NEXTSTATE2ENABLE.items(): + for cond, next_state in State402.NEXTSTATE2ANY.items(): if _from in cond: return next_state @@ -110,35 +116,45 @@ class OperationMode(object): OPEN_LOOP_VECTOR_MODE = -2 CODE2NAME = { - NO_MODE : 'NO MODE', - PROFILED_POSITION : 'PROFILED POSITION', - VELOCITY : 'VELOCITY', - PROFILED_VELOCITY : 'PROFILED VELOCITY', - PROFILED_TORQUE : 'PROFILED TORQUE', - HOMING : 'HOMING', - INTERPOLATED_POSITION : 'INTERPOLATED POSITION' + NO_MODE: 'NO MODE', + PROFILED_POSITION: 'PROFILED POSITION', + VELOCITY: 'VELOCITY', + PROFILED_VELOCITY: 'PROFILED VELOCITY', + PROFILED_TORQUE: 'PROFILED TORQUE', + HOMING: 'HOMING', + INTERPOLATED_POSITION: 'INTERPOLATED POSITION', + CYCLIC_SYNCHRONOUS_POSITION: 'CYCLIC SYNCHRONOUS POSITION', + CYCLIC_SYNCHRONOUS_VELOCITY: 'CYCLIC SYNCHRONOUS VELOCITY', + CYCLIC_SYNCHRONOUS_TORQUE: 'CYCLIC SYNCHRONOUS TORQUE', } NAME2CODE = { - 'NO MODE' : NO_MODE, - 'PROFILED POSITION' : PROFILED_POSITION, - 'VELOCITY' : VELOCITY, - 'PROFILED VELOCITY' : PROFILED_VELOCITY, - 'PROFILED TORQUE' : PROFILED_TORQUE, - 'HOMING' : HOMING, - 'INTERPOLATED POSITION' : INTERPOLATED_POSITION + 'NO MODE': NO_MODE, + 'PROFILED POSITION': PROFILED_POSITION, + 'VELOCITY': VELOCITY, + 'PROFILED VELOCITY': PROFILED_VELOCITY, + 'PROFILED TORQUE': PROFILED_TORQUE, + 'HOMING': HOMING, + 'INTERPOLATED POSITION': INTERPOLATED_POSITION, + 'CYCLIC SYNCHRONOUS POSITION': CYCLIC_SYNCHRONOUS_POSITION, + 'CYCLIC SYNCHRONOUS VELOCITY': CYCLIC_SYNCHRONOUS_VELOCITY, + 'CYCLIC SYNCHRONOUS TORQUE': CYCLIC_SYNCHRONOUS_TORQUE, } SUPPORTED = { - 'NO MODE' : 0x0, - 'PROFILED POSITION' : 0x1, - 'VELOCITY' : 0x2, - 'PROFILED VELOCITY' : 0x4, - 'PROFILED TORQUE' : 0x8, - 'HOMING' : 0x20, - 'INTERPOLATED POSITION' : 0x40 + 'NO MODE': 0x0000, + 'PROFILED POSITION': 0x0001, + 'VELOCITY': 0x0002, + 'PROFILED VELOCITY': 0x0004, + 'PROFILED TORQUE': 0x0008, + 'HOMING': 0x0020, + 'INTERPOLATED POSITION': 0x0040, + 'CYCLIC SYNCHRONOUS POSITION': 0x0080, + 'CYCLIC SYNCHRONOUS VELOCITY': 0x0100, + 'CYCLIC SYNCHRONOUS TORQUE': 0x0200, } + class Homing(object): CW_START = 0x10 CW_HALT = 0x100 @@ -154,23 +170,23 @@ class Homing(object): HM_NO_HOMING_OPERATION = 0 HM_ON_THE_NEGATIVE_LIMIT_SWITCH_AND_INDEX_PULSE = 1 HM_ON_THE_POSITIVE_LIMIT_SWITCH_AND_INDEX_PULSE = 2 - HM_ON_THE_POSITIVE_HOME_SWITCH_AND_INDEX_PULSE = [3, 4] - HM_ON_THE_NEGATIVE_HOME_SWITCH_AND_INDEX_PULSE = [5, 6] + HM_ON_THE_POSITIVE_HOME_SWITCH_AND_INDEX_PULSE = (3, 4) + HM_ON_THE_NEGATIVE_HOME_SWITCH_AND_INDEX_PULSE = (5, 6) HM_ON_THE_NEGATIVE_LIMIT_SWITCH = 17 HM_ON_THE_POSITIVE_LIMIT_SWITCH = 18 - HM_ON_THE_POSITIVE_HOME_SWITCH = [19, 20] - HM_ON_THE_NEGATIVE_HOME_SWITCH = [21, 22] + HM_ON_THE_POSITIVE_HOME_SWITCH = (19, 20) + HM_ON_THE_NEGATIVE_HOME_SWITCH = (21, 22) HM_ON_NEGATIVE_INDEX_PULSE = 33 HM_ON_POSITIVE_INDEX_PULSE = 34 HM_ON_CURRENT_POSITION = 35 STATES = { - 'IN PROGRESS' : [0x3400, 0x0000], - 'INTERRUPTED' : [0x3400, 0x0400], - 'ATTAINED' : [0x3400, 0x1000], - 'TARGET REACHED' : [0x3400, 0x1400], - 'ERROR VELOCITY IS NOT ZERO' : [0x3400, 0x2000], - 'ERROR VELOCITY IS ZERO' : [0x3400, 0x2400] + 'IN PROGRESS': (0x3400, 0x0000), + 'INTERRUPTED': (0x3400, 0x0400), + 'ATTAINED': (0x3400, 0x1000), + 'TARGET REACHED': (0x3400, 0x1400), + 'ERROR VELOCITY IS NOT ZERO': (0x3400, 0x2000), + 'ERROR VELOCITY IS ZERO': (0x3400, 0x2400), } @@ -185,26 +201,45 @@ class BaseNode402(RemoteNode): :type object_dictionary: :class:`str`, :class:`canopen.ObjectDictionary` """ + TIMEOUT_RESET_FAULT = 0.4 # seconds + TIMEOUT_SWITCH_OP_MODE = 0.5 # seconds + TIMEOUT_SWITCH_STATE_FINAL = 0.8 # seconds + TIMEOUT_SWITCH_STATE_SINGLE = 0.4 # seconds + TIMEOUT_CHECK_TPDO = 0.2 # seconds + TIMEOUT_HOMING_DEFAULT = 30 # seconds + def __init__(self, node_id, object_dictionary): super(BaseNode402, self).__init__(node_id, object_dictionary) - self.tpdo_values = dict() # { index: TPDO_value } - self.rpdo_pointers = dict() # { index: RPDO_pointer } - - def setup_402_state_machine(self): - """Configure the state machine by searching for a TPDO that has the - StatusWord mapped. - :raise ValueError: If the the node can't find a Statusword configured - in the any of the TPDOs + self.tpdo_values = {} # { index: value from last received TPDO } + self.tpdo_pointers = {} # { index: pdo.Map instance } + self.rpdo_pointers = {} # { index: pdo.Map instance } + + def setup_402_state_machine(self, read_pdos=True): + """Configure the state machine by searching for a TPDO that has the StatusWord mapped. + + :param bool read_pdos: Upload current PDO configuration from node. + :raises ValueError: + If the the node can't find a Statusword configured in any of the TPDOs. """ - self.nmt.state = 'PRE-OPERATIONAL' # Why is this necessary? - self.setup_pdos() + self.setup_pdos(read_pdos) self._check_controlword_configured() self._check_statusword_configured() - self.nmt.state = 'OPERATIONAL' - self.state = 'SWITCH ON DISABLED' # Why change state? + self._check_op_mode_configured() + + def setup_pdos(self, upload=True): + """Find the relevant PDO configuration to handle the state machine. - def setup_pdos(self): - self.pdo.read() # TPDO and RPDO configurations + :param bool upload: + Retrieve up-to-date configuration via SDO. If False, the node's mappings must + already be configured in the object, matching the drive's settings. + :raises AssertionError: + When the node's NMT state disallows SDOs for reading the PDO configuration. + """ + if upload: + assert self.nmt.state in 'PRE-OPERATIONAL', 'OPERATIONAL' + self.pdo.read() # TPDO and RPDO configurations + else: + self.pdo.subscribe() # Get notified on reception, usually a side-effect of read() self._init_tpdo_values() self._init_rpdo_pointers() @@ -216,9 +251,10 @@ def _init_tpdo_values(self): logger.debug('Configured TPDO: {0}'.format(obj.index)) if obj.index not in self.tpdo_values: self.tpdo_values[obj.index] = 0 + self.tpdo_pointers[obj.index] = obj def _init_rpdo_pointers(self): - # If RPDOs have overlapping indecies, rpdo_pointers will point to + # If RPDOs have overlapping indecies, rpdo_pointers will point to # the first RPDO that has that index configured. for rpdo in self.rpdo.values(): if rpdo.enabled: @@ -228,91 +264,117 @@ def _init_rpdo_pointers(self): self.rpdo_pointers[obj.index] = obj def _check_controlword_configured(self): - if 0x6040 not in self.rpdo_pointers: # Controlword + if 0x6040 not in self.rpdo_pointers: # Controlword logger.warning( "Controlword not configured in node {0}'s PDOs. Using SDOs can cause slow performance.".format( self.id)) def _check_statusword_configured(self): - if 0x6041 not in self.tpdo_values: # Statusword - raise ValueError( + if 0x6041 not in self.tpdo_values: # Statusword + logger.warning( "Statusword not configured in node {0}'s PDOs. Using SDOs can cause slow performance.".format( self.id)) + def _check_op_mode_configured(self): + if 0x6060 not in self.rpdo_pointers: # Operation Mode + logger.warning( + "Operation Mode not configured in node {0}'s PDOs. Using SDOs can cause slow performance.".format( + self.id)) + if 0x6061 not in self.tpdo_values: # Operation Mode Display + logger.warning( + "Operation Mode Display not configured in node {0}'s PDOs. Using SDOs can cause slow performance.".format( + self.id)) + def reset_from_fault(self): - """Reset node from fault and set it to Operation Enable state - """ + """Reset node from fault and set it to Operation Enable state.""" if self.state == 'FAULT': # Resets the Fault Reset bit (rising edge 0 -> 1) self.controlword = State402.CW_DISABLE_VOLTAGE - timeout = time.time() + 0.4 # 400 ms - + # FIXME! The rising edge happens with the transitions toward OPERATION + # ENABLED below, but until then the loop will always reach the timeout! + timeout = time.monotonic() + self.TIMEOUT_RESET_FAULT while self.is_faulted(): - if time.time() > timeout: + if time.monotonic() > timeout: break - time.sleep(0.01) # 10 ms + self.check_statusword() self.state = 'OPERATION ENABLED' - + def is_faulted(self): - return self.statusword & State402.SW_MASK['FAULT'][0] == State402.SW_MASK['FAULT'][1] - - def homing(self, timeout=30, set_new_home=True): - """Function to execute the configured Homing Method on the node - :param int timeout: Timeout value (default: 30) - :param bool set_new_home: Defines if the node should set the home offset - object (0x607C) to the current position after the homing procedure (default: true) - :return: If the homing was complete with success + bitmask, bits = State402.SW_MASK['FAULT'] + return self.statusword & bitmask == bits + + def _homing_status(self): + """Interpret the current Statusword bits as homing state string.""" + # Wait to make sure a TPDO was received + self.check_statusword() + status = None + for key, value in Homing.STATES.items(): + bitmask, bits = value + if self.statusword & bitmask == bits: + status = key + return status + + def is_homed(self, restore_op_mode=False): + """Switch to homing mode and determine its status. + + :param bool restore_op_mode: Switch back to the previous operation mode when done. + :return: If the status indicates successful homing. + :rtype: bool + """ + previous_op_mode = self.op_mode + if previous_op_mode != 'HOMING': + logger.info('Switch to HOMING from %s', previous_op_mode) + self.op_mode = 'HOMING' # blocks until confirmed + homingstatus = self._homing_status() + if restore_op_mode: + self.op_mode = previous_op_mode + return homingstatus in ('TARGET REACHED', 'ATTAINED') + + def homing(self, timeout=None, restore_op_mode=False): + """Execute the configured Homing method on the node. + + :param int timeout: Timeout value (default: 30, zero to disable). + :param bool restore_op_mode: + Switch back to the previous operation mode after homing (default: no). + :return: If the homing was complete with success. :rtype: bool """ - previus_op_mode = self.op_mode - self.state = 'SWITCHED ON' + if timeout is None: + timeout = self.TIMEOUT_HOMING_DEFAULT + if restore_op_mode: + previous_op_mode = self.op_mode self.op_mode = 'HOMING' # The homing process will initialize at operation enabled self.state = 'OPERATION ENABLED' - homingstatus = 'IN PROGRESS' - self.controlword = State402.CW_OPERATION_ENABLED | Homing.CW_START - t = time.time() + timeout + homingstatus = 'UNKNOWN' + self.controlword = State402.CW_OPERATION_ENABLED | Homing.CW_START # does not block + # Wait for one extra cycle, to make sure the controlword was received + self.check_statusword() + t = time.monotonic() + timeout try: while homingstatus not in ('TARGET REACHED', 'ATTAINED'): - for key, value in Homing.STATES.items(): - # check if the value after applying the bitmask (value[0]) - # corresponds with the value[1] to determine the current status - bitmaskvalue = self.statusword & value[0] - if bitmaskvalue == value[1]: - homingstatus = key - if homingstatus in ('INTERRUPTED', 'ERROR VELOCITY IS NOT ZERO', 'ERROR VELOCITY IS ZERO'): - raise RuntimeError ('Unable to home. Reason: {0}'.format(homingstatus)) - time.sleep(0.001) - if time.time() > t: + homingstatus = self._homing_status() + if homingstatus in ('INTERRUPTED', 'ERROR VELOCITY IS NOT ZERO', + 'ERROR VELOCITY IS ZERO'): + raise RuntimeError('Unable to home. Reason: {0}'.format(homingstatus)) + if timeout and time.monotonic() > t: raise RuntimeError('Unable to home, timeout reached') - if set_new_home: - actual_position = self.sdo[0x6063].raw - self.sdo[0x607C].raw = actual_position # home offset (0x607C) - logger.info('Homing offset set to {0}'.format(actual_position)) logger.info('Homing mode carried out successfully.') return True except RuntimeError as e: logger.info(str(e)) finally: - self.op_mode = previus_op_mode + if restore_op_mode: + self.op_mode = previous_op_mode return False @property def op_mode(self): - """ - :return: Return the operation mode stored in the object 0x6061 through SDO - :rtype: int - """ - return OperationMode.CODE2NAME[self.sdo[0x6061].raw] + """The node's Operation Mode stored in the object 0x6061. - @op_mode.setter - def op_mode(self, mode): - """Function to define the operation mode of the node - :param string mode: Mode to define. - :return: Return if the operation mode was set with success or not - :rtype: bool + Uses SDO or PDO to access the current value. The modes are passed as one of the + following strings: - The modes can be: - 'NO MODE' - 'PROFILED POSITION' - 'VELOCITY' @@ -325,37 +387,48 @@ def op_mode(self, mode): - 'CYCLIC SYNCHRONOUS TORQUE' - 'OPEN LOOP SCALAR MODE' - 'OPEN LOOP VECTOR MODE' + + :raises TypeError: When setting a mode not advertised as supported by the node. + :raises RuntimeError: If the switch is not confirmed within the configured timeout. """ + try: + pdo = self.tpdo_pointers[0x6061].pdo_parent + if pdo.is_periodic: + timestamp = pdo.wait_for_reception(timeout=self.TIMEOUT_CHECK_TPDO) + if timestamp is None: + raise RuntimeError("Timeout getting node {0}'s mode of operation.".format( + self.id)) + code = self.tpdo_values[0x6061] + except KeyError: + logger.warning('The object 0x6061 is not a configured TPDO, fallback to SDO') + code = self.sdo[0x6061].raw + return OperationMode.CODE2NAME[code] + + @op_mode.setter + def op_mode(self, mode): try: if not self.is_op_mode_supported(mode): raise TypeError( - 'Operation mode {0} not suppported on node {1}.'.format(mode, self.id)) - - start_state = self.state - - if self.state == 'OPERATION ENABLED': - self.state = 'SWITCHED ON' - # ensure the node does not move with an old value - self._clear_target_values() # Shouldn't this happen before it's switched on? - - # operation mode - self.sdo[0x6060].raw = OperationMode.NAME2CODE[mode] - - timeout = time.time() + 0.5 # 500 ms + 'Operation mode {m} not suppported on node {n}.'.format(n=self.id, m=mode)) + # Update operation mode in RPDO if possible, fall back to SDO + if 0x6060 in self.rpdo_pointers: + self.rpdo_pointers[0x6060].raw = OperationMode.NAME2CODE[mode] + pdo = self.rpdo_pointers[0x6060].pdo_parent + if not pdo.is_periodic: + pdo.transmit() + else: + self.sdo[0x6060].raw = OperationMode.NAME2CODE[mode] + timeout = time.monotonic() + self.TIMEOUT_SWITCH_OP_MODE while self.op_mode != mode: - if time.time() > timeout: + if time.monotonic() > timeout: raise RuntimeError( "Timeout setting node {0}'s new mode of operation to {1}.".format( self.id, mode)) - return True + logger.info('Set node {n} operation mode to {m}.'.format(n=self.id, m=mode)) except SdoCommunicationError as e: logger.warning('[SDO communication error] Cause: {0}'.format(str(e))) except (RuntimeError, ValueError) as e: logger.warning('{0}'.format(str(e))) - finally: - self.state = start_state # why? - logger.info('Set node {n} operation mode to {m}.'.format(n=self.id , m=mode)) - return False def _clear_target_values(self): # [target velocity, target position, target torque] @@ -364,27 +437,38 @@ def _clear_target_values(self): self.sdo[target_index].raw = 0 def is_op_mode_supported(self, mode): - """Function to check if the operation mode is supported by the node - :param int mode: Operation mode - :return: If the operation mode is supported + """Check if the operation mode is supported by the node. + + The object listing the supported modes is retrieved once using SDO, then cached + for later checks. + + :param str mode: Same format as the :attr:`op_mode` property. + :return: If the operation mode is supported. :rtype: bool """ - mode_support = self.sdo[0x6502].raw & OperationMode.SUPPORTED[mode] - return mode_support == OperationMode.SUPPORTED[mode] + if not hasattr(self, '_op_mode_support'): + # Cache value only on first lookup, this object should never change. + self._op_mode_support = self.sdo[0x6502].raw + logger.info('Caching node {n} supported operation modes 0x{m:04X}'.format( + n=self.id, m=self._op_mode_support)) + bits = OperationMode.SUPPORTED[mode] + return self._op_mode_support & bits == bits def on_TPDOs_update_callback(self, mapobject): - """This function receives a map object. - this map object is then used for changing the - :param mapobject: :class: `canopen.objectdictionary.Variable` + """Cache updated values from a TPDO received from this node. + + :param mapobject: The received PDO message. + :type mapobject: canopen.pdo.Map """ for obj in mapobject: self.tpdo_values[obj.index] = obj.raw @property def statusword(self): - """Returns the last read value of the Statusword (0x6041) from the device. - If the the object 0x6041 is not configured in any TPDO it will fallback to the SDO mechanism - and try to tget the value. + """Return the last read value of the Statusword (0x6041) from the device. + + If the object 0x6041 is not configured in any TPDO it will fall back to the SDO + mechanism and try to get the value. """ try: return self.tpdo_values[0x6041] @@ -392,70 +476,96 @@ def statusword(self): logger.warning('The object 0x6041 is not a configured TPDO, fallback to SDO') return self.sdo[0x6041].raw + def check_statusword(self, timeout=None): + """Report an up-to-date reading of the Statusword (0x6041) from the device. + + If the TPDO with the Statusword is configured as periodic, this method blocks + until one was received. Otherwise, it uses the SDO fallback of the ``statusword`` + property. + + :param timeout: Maximum time in seconds to wait for TPDO reception. + :raises RuntimeError: Occurs when the given timeout expires without a TPDO. + :return: Updated value of the ``statusword`` property. + :rtype: int + """ + if 0x6041 in self.tpdo_pointers: + pdo = self.tpdo_pointers[0x6041].pdo_parent + if pdo.is_periodic: + timestamp = pdo.wait_for_reception(timeout or self.TIMEOUT_CHECK_TPDO) + if timestamp is None: + raise RuntimeError('Timeout waiting for updated statusword') + else: + return self.sdo[0x6041].raw + return self.statusword + @property def controlword(self): + """Send a state change command using PDO or SDO. + + :param int value: Controlword value to set. + :raises RuntimeError: Read access to the controlword is not intended. + """ raise RuntimeError('The Controlword is write-only.') @controlword.setter def controlword(self, value): - """Send the state using PDO or SDO objects. - :param int value: State value to send in the message - """ if 0x6040 in self.rpdo_pointers: self.rpdo_pointers[0x6040].raw = value - self.rpdo_pointers[0x6040].pdo_parent.transmit() + pdo = self.rpdo_pointers[0x6040].pdo_parent + if not pdo.is_periodic: + pdo.transmit() else: self.sdo[0x6040].raw = value @property def state(self): - """Attribute to get or set node's state as a string for the DS402 State Machine. - States of the node can be one of: - - 'NOT READY TO SWITCH ON' + """Manipulate current state of the DS402 State Machine on the node. + + Uses the last received Statusword value for read access, and manipulates the + :attr:`controlword` for changing states. The states are passed as one of the + following strings: + + - 'NOT READY TO SWITCH ON' (cannot be switched to deliberately) - 'SWITCH ON DISABLED' - 'READY TO SWITCH ON' - 'SWITCHED ON' - 'OPERATION ENABLED' - - 'FAULT' - - 'FAULT REACTION ACTIVE' + - 'FAULT' (cannot be switched to deliberately) + - 'FAULT REACTION ACTIVE' (cannot be switched to deliberately) - 'QUICK STOP ACTIVE' + - 'DISABLE VOLTAGE' (only as a command when writing) + + :raises RuntimeError: If the switch is not confirmed within the configured timeout. + :raises ValueError: Trying to execute a illegal transition in the state machine. """ for state, mask_val_pair in State402.SW_MASK.items(): - mask = mask_val_pair[0] - state_value = mask_val_pair[1] - masked_value = self.statusword & mask - if masked_value == state_value: + bitmask, bits = mask_val_pair + if self.statusword & bitmask == bits: return state return 'UNKNOWN' @state.setter def state(self, target_state): - """ Defines the state for the DS402 state machine - States to switch to can be one of: - - 'SWITCH ON DISABLED' - - 'DISABLE VOLTAGE' - - 'READY TO SWITCH ON' - - 'SWITCHED ON' - - 'OPERATION ENABLED' - - 'QUICK STOP ACTIVE' - :param string target_state: Target state - :raise RuntimeError: Occurs when the time defined to change the state is reached - :raise ValueError: Occurs when trying to execute a ilegal transition in the sate machine - """ - timeout = time.time() + 0.8 # 800 ms + timeout = time.monotonic() + self.TIMEOUT_SWITCH_STATE_FINAL while self.state != target_state: next_state = self._next_state(target_state) if self._change_state(next_state): - continue - if time.time() > timeout: + continue + if time.monotonic() > timeout: raise RuntimeError('Timeout when trying to change state') - time.sleep(0.01) # 10 ms + self.check_statusword() def _next_state(self, target_state): - if target_state == 'OPERATION ENABLED': - return State402.next_state_for_enabling(self.state) - else: + if target_state in ('NOT READY TO SWITCH ON', + 'FAULT REACTION ACTIVE', + 'FAULT'): + raise ValueError( + 'Target state {} cannot be entered programmatically'.format(target_state)) + from_state = self.state + if (from_state, target_state) in State402.TRANSITIONTABLE: return target_state + else: + return State402.next_state_indirect(from_state) def _change_state(self, target_state): try: @@ -463,9 +573,9 @@ def _change_state(self, target_state): except KeyError: raise ValueError( 'Illegal state transition from {f} to {t}'.format(f=self.state, t=target_state)) - timeout = time.time() + 0.4 # 400 ms + timeout = time.monotonic() + self.TIMEOUT_SWITCH_STATE_SINGLE while self.state != target_state: - if time.time() > timeout: + if time.monotonic() > timeout: return False - time.sleep(0.01) # 10 ms + self.check_statusword() return True diff --git a/canopen/profiles/tools/test_p402_states.py b/canopen/profiles/tools/test_p402_states.py new file mode 100644 index 00000000..39f085f5 --- /dev/null +++ b/canopen/profiles/tools/test_p402_states.py @@ -0,0 +1,37 @@ +"""Verification script to diagnose automatic state transitions. + +This is meant to be run for verifying changes to the DS402 power state +machine code. For each target state, it just lists the next +intermediate state which would be set automatically, depending on the +assumed current state. +""" + +from canopen.objectdictionary import ObjectDictionary +from canopen.profiles.p402 import State402, BaseNode402 + + +if __name__ == '__main__': + n = BaseNode402(1, ObjectDictionary()) + + for target_state in State402.SW_MASK: + print('\n--- Target =', target_state, '---') + for from_state in State402.SW_MASK: + if target_state == from_state: + continue + if (from_state, target_state) in State402.TRANSITIONTABLE: + print('direct:\t{} -> {}'.format(from_state, target_state)) + else: + next_state = State402.next_state_indirect(from_state) + if not next_state: + print('FAIL:\t{} -> {}'.format(from_state, next_state)) + else: + print('\t{} -> {} ...'.format(from_state, next_state)) + + try: + while from_state != target_state: + n.tpdo_values[0x6041] = State402.SW_MASK[from_state][1] + next_state = n._next_state(target_state) + print('\t\t-> {}'.format(next_state)) + from_state = next_state + except ValueError: + print('\t\t-> disallowed!') diff --git a/canopen/sdo/base.py b/canopen/sdo/base.py index 8f1fcb18..3c3d0bbe 100644 --- a/canopen/sdo/base.py +++ b/canopen/sdo/base.py @@ -1,4 +1,5 @@ import binascii +from typing import Iterable, Union try: from collections.abc import Mapping except ImportError: @@ -26,13 +27,18 @@ class SdoBase(Mapping): #: The CRC algorithm used for block transfers crc_cls = CrcXmodem - def __init__(self, rx_cobid, tx_cobid, od): + def __init__( + self, + rx_cobid: int, + tx_cobid: int, + od: objectdictionary.ObjectDictionary, + ): """ - :param int rx_cobid: + :param rx_cobid: COB-ID that the server receives on (usually 0x600 + node ID) - :param int tx_cobid: + :param tx_cobid: COB-ID that the server responds with (usually 0x580 + node ID) - :param canopen.ObjectDictionary od: + :param od: Object Dictionary to use for communication """ self.rx_cobid = rx_cobid @@ -40,7 +46,9 @@ def __init__(self, rx_cobid, tx_cobid, od): self.network = None self.od = od - def __getitem__(self, index): + def __getitem__( + self, index: Union[str, int] + ) -> Union["Variable", "Array", "Record"]: entry = self.od[index] if isinstance(entry, objectdictionary.Variable): return Variable(self, entry) @@ -49,70 +57,82 @@ def __getitem__(self, index): elif isinstance(entry, objectdictionary.Record): return Record(self, entry) - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(self.od) - def __len__(self): + def __len__(self) -> int: return len(self.od) - def __contains__(self, key): + def __contains__(self, key: Union[int, str]) -> bool: return key in self.od + def upload(self, index: int, subindex: int) -> bytes: + raise NotImplementedError() + + def download( + self, + index: int, + subindex: int, + data: bytes, + force_segment: bool = False, + ) -> None: + raise NotImplementedError() + class Record(Mapping): - def __init__(self, sdo_node, od): + def __init__(self, sdo_node: SdoBase, od: objectdictionary.ObjectDictionary): self.sdo_node = sdo_node self.od = od - def __getitem__(self, subindex): + def __getitem__(self, subindex: Union[int, str]) -> "Variable": return Variable(self.sdo_node, self.od[subindex]) - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(self.od) - def __len__(self): + def __len__(self) -> int: return len(self.od) - def __contains__(self, subindex): + def __contains__(self, subindex: Union[int, str]) -> bool: return subindex in self.od class Array(Mapping): - def __init__(self, sdo_node, od): + def __init__(self, sdo_node: SdoBase, od: objectdictionary.ObjectDictionary): self.sdo_node = sdo_node self.od = od - def __getitem__(self, subindex): + def __getitem__(self, subindex: Union[int, str]) -> "Variable": return Variable(self.sdo_node, self.od[subindex]) - def __iter__(self): + def __iter__(self) -> Iterable[int]: return iter(range(1, len(self) + 1)) - def __len__(self): + def __len__(self) -> int: return self[0].raw - def __contains__(self, subindex): + def __contains__(self, subindex: int) -> bool: return 0 <= subindex <= len(self) class Variable(variable.Variable): """Access object dictionary variable values using SDO protocol.""" - def __init__(self, sdo_node, od): + def __init__(self, sdo_node: SdoBase, od: objectdictionary.ObjectDictionary): self.sdo_node = sdo_node variable.Variable.__init__(self, od) - def get_data(self): + def get_data(self) -> bytes: return self.sdo_node.upload(self.od.index, self.od.subindex) - def set_data(self, data): + def set_data(self, data: bytes): force_segment = self.od.data_type == objectdictionary.DOMAIN self.sdo_node.download(self.od.index, self.od.subindex, data, force_segment) def open(self, mode="rb", encoding="ascii", buffering=1024, size=None, - block_transfer=False): + block_transfer=False, request_crc_support=True): """Open the data stream as a file like object. :param str mode: @@ -136,9 +156,11 @@ def open(self, mode="rb", encoding="ascii", buffering=1024, size=None, Size of data to that will be transmitted. :param bool block_transfer: If block transfer should be used. + :param bool request_crc_support: + If crc calculation should be requested when using block transfer :returns: A file like object. """ return self.sdo_node.open(self.od.index, self.od.subindex, mode, - encoding, buffering, size, block_transfer) + encoding, buffering, size, block_transfer, request_crc_support=request_crc_support) diff --git a/canopen/sdo/client.py b/canopen/sdo/client.py index 44159c49..0ed083e4 100644 --- a/canopen/sdo/client.py +++ b/canopen/sdo/client.py @@ -86,6 +86,7 @@ def request_response(self, sdo_request): except SdoCommunicationError as e: retries_left -= 1 if not retries_left: + self.abort(0x5040000) raise logger.warning(str(e)) @@ -98,16 +99,15 @@ def abort(self, abort_code=0x08000000): self.send_request(request) logger.error("Transfer aborted by client with code 0x{:08X}".format(abort_code)) - def upload(self, index, subindex): + def upload(self, index: int, subindex: int) -> bytes: """May be called to make a read operation without an Object Dictionary. - :param int index: + :param index: Index of object to read. - :param int subindex: + :param subindex: Sub-index of object to read. :return: A data object. - :rtype: bytes :raises canopen.SdoCommunicationError: On unexpected response or timeout. @@ -132,16 +132,22 @@ def upload(self, index, subindex): data = data[0:size] return data - def download(self, index, subindex, data, force_segment=False): + def download( + self, + index: int, + subindex: int, + data: bytes, + force_segment: bool = False, + ) -> None: """May be called to make a write operation without an Object Dictionary. - :param int index: + :param index: Index of object to write. - :param int subindex: + :param subindex: Sub-index of object to write. - :param bytes data: + :param data: Data to be written. - :param bool force_segment: + :param force_segment: Force use of segmented transfer regardless of data size. :raises canopen.SdoCommunicationError: @@ -155,7 +161,7 @@ def download(self, index, subindex, data, force_segment=False): fp.close() def open(self, index, subindex=0, mode="rb", encoding="ascii", - buffering=1024, size=None, block_transfer=False, force_segment=False): + buffering=1024, size=None, block_transfer=False, force_segment=False, request_crc_support=True): """Open the data stream as a file like object. :param int index: @@ -185,14 +191,16 @@ def open(self, index, subindex=0, mode="rb", encoding="ascii", If block transfer should be used. :param bool force_segment: Force use of segmented download regardless of data size. - + :param bool request_crc_support: + If crc calculation should be requested when using block transfer + :returns: A file like object. """ buffer_size = buffering if buffering > 1 else io.DEFAULT_BUFFER_SIZE if "r" in mode: if block_transfer: - raw_stream = BlockUploadStream(self, index, subindex) + raw_stream = BlockUploadStream(self, index, subindex, request_crc_support=request_crc_support) else: raw_stream = ReadableStream(self, index, subindex) if buffering: @@ -201,7 +209,7 @@ def open(self, index, subindex=0, mode="rb", encoding="ascii", return raw_stream if "w" in mode: if block_transfer: - raw_stream = BlockDownloadStream(self, index, subindex, size) + raw_stream = BlockDownloadStream(self, index, subindex, size, request_crc_support=request_crc_support) else: raw_stream = WritableStream(self, index, subindex, size, force_segment) if buffering: @@ -446,7 +454,7 @@ class BlockUploadStream(io.RawIOBase): crc_supported = False - def __init__(self, sdo_client, index, subindex=0): + def __init__(self, sdo_client, index, subindex=0, request_crc_support=True): """ :param canopen.sdo.SdoClient sdo_client: The SDO client to use for reading. @@ -454,6 +462,8 @@ def __init__(self, sdo_client, index, subindex=0): Object dictionary index to read from. :param int subindex: Object dictionary sub-index to read from. + :param bool request_crc_support: + If crc calculation should be requested when using block transfer """ self._done = False self.sdo_client = sdo_client @@ -466,7 +476,9 @@ def __init__(self, sdo_client, index, subindex=0): sdo_client.rx_cobid - 0x600) # Initiate Block Upload request = bytearray(8) - command = REQUEST_BLOCK_UPLOAD | INITIATE_BLOCK_TRANSFER | CRC_SUPPORTED + command = REQUEST_BLOCK_UPLOAD | INITIATE_BLOCK_TRANSFER + if request_crc_support: + command |= CRC_SUPPORTED struct.pack_into("> 2) & 0x3) else: size = 4 - self.download(index, subindex, request[4:4 + size]) + self._node.set_data(index, subindex, request[4:4 + size], check_writable=True) else: logger.info("Initiating segmented download for 0x%X:%d", index, subindex) if command & SIZE_SPECIFIED: @@ -181,30 +181,35 @@ def abort(self, abort_code=0x08000000): self.send_response(data) # logger.error("Transfer aborted with code 0x{:08X}".format(abort_code)) - def upload(self, index, subindex): + def upload(self, index: int, subindex: int) -> bytes: """May be called to make a read operation without an Object Dictionary. - :param int index: + :param index: Index of object to read. - :param int subindex: + :param subindex: Sub-index of object to read. :return: A data object. - :rtype: bytes :raises canopen.SdoAbortedError: When node responds with an error. """ return self._node.get_data(index, subindex) - def download(self, index, subindex, data, force_segment=False): - """May be called to make a write operation without an Object Dictionary. + def download( + self, + index: int, + subindex: int, + data: bytes, + force_segment: bool = False, + ): + """May be called to make a write operation without an Object Dictionary. - :param int index: + :param index: Index of object to write. - :param int subindex: + :param subindex: Sub-index of object to write. - :param bytes data: + :param data: Data to be written. :raises canopen.SdoAbortedError: diff --git a/canopen/sync.py b/canopen/sync.py index 3619cfff..32248279 100644 --- a/canopen/sync.py +++ b/canopen/sync.py @@ -1,5 +1,8 @@ +from typing import Optional + + class SyncProducer(object): """Transmits a SYNC message periodically.""" @@ -8,22 +11,22 @@ class SyncProducer(object): def __init__(self, network): self.network = network - self.period = None + self.period: Optional[float] = None self._task = None - def transmit(self, count=None): + def transmit(self, count: Optional[int] = None): """Send out a SYNC message once. - :param int count: + :param count: Counter to add in message. """ data = [count] if count is not None else [] self.network.send_message(self.cob_id, data) - def start(self, period=None): + def start(self, period: Optional[float] = None): """Start periodic transmission of SYNC message in a background thread. - :param float period: + :param period: Period of SYNC message in seconds. """ if period is not None: diff --git a/canopen/timestamp.py b/canopen/timestamp.py index 8215affc..e96f7576 100644 --- a/canopen/timestamp.py +++ b/canopen/timestamp.py @@ -1,5 +1,6 @@ import time import struct +from typing import Optional # 1 Jan 1984 OFFSET = 441763200 @@ -18,7 +19,7 @@ class TimeProducer(object): def __init__(self, network): self.network = network - def transmit(self, timestamp=None): + def transmit(self, timestamp: Optional[float] = None): """Send out the TIME message once. :param float timestamp: diff --git a/canopen/variable.py b/canopen/variable.py index edb977c3..2357d162 100644 --- a/canopen/variable.py +++ b/canopen/variable.py @@ -1,4 +1,5 @@ import logging +from typing import Union try: from collections.abc import Mapping except ImportError: @@ -11,7 +12,7 @@ class Variable(object): - def __init__(self, od): + def __init__(self, od: objectdictionary.Variable): self.od = od #: Description of this variable from Object Dictionary, overridable self.name = od.name @@ -24,23 +25,23 @@ def __init__(self, od): #: Holds a local, overridable copy of the Object Subindex self.subindex = od.subindex - def get_data(self): + def get_data(self) -> bytes: raise NotImplementedError("Variable is not readable") - def set_data(self, data): + def set_data(self, data: bytes): raise NotImplementedError("Variable is not writable") @property - def data(self): + def data(self) -> bytes: """Byte representation of the object as :class:`bytes`.""" return self.get_data() @data.setter - def data(self, data): + def data(self, data: bytes): self.set_data(data) @property - def raw(self): + def raw(self) -> Union[int, bool, float, str, bytes]: """Raw representation of the object. This table lists the translations between object dictionary data types @@ -81,14 +82,14 @@ def raw(self): return value @raw.setter - def raw(self, value): + def raw(self, value: Union[int, bool, float, str, bytes]): logger.debug("Writing %s (0x%X:%d) = %r", self.name, self.index, self.subindex, value) self.data = self.od.encode_raw(value) @property - def phys(self): + def phys(self) -> Union[int, bool, float, str, bytes]: """Physical value scaled with some factor (defaults to 1). On object dictionaries that support specifying a factor, this can be @@ -101,26 +102,26 @@ def phys(self): return value @phys.setter - def phys(self, value): + def phys(self, value: Union[int, bool, float, str, bytes]): self.raw = self.od.encode_phys(value) @property - def desc(self): + def desc(self) -> str: """Converts to and from a description of the value as a string.""" value = self.od.decode_desc(self.raw) logger.debug("Description is '%s'", value) return value @desc.setter - def desc(self, desc): + def desc(self, desc: str): self.raw = self.od.encode_desc(desc) @property - def bits(self): + def bits(self) -> "Bits": """Access bits using integers, slices, or bit descriptions.""" return Bits(self) - def read(self, fmt="raw"): + def read(self, fmt: str = "raw") -> Union[int, bool, float, str, bytes]: """Alternative way of reading using a function instead of attributes. May be useful for asynchronous reading. @@ -141,7 +142,9 @@ def read(self, fmt="raw"): elif fmt == "desc": return self.desc - def write(self, value, fmt="raw"): + def write( + self, value: Union[int, bool, float, str, bytes], fmt: str = "raw" + ) -> None: """Alternative way of writing using a function instead of attributes. May be useful for asynchronous writing. @@ -162,7 +165,7 @@ def write(self, value, fmt="raw"): class Bits(Mapping): - def __init__(self, variable): + def __init__(self, variable: Variable): self.variable = variable self.read() @@ -176,10 +179,10 @@ def _get_bits(key): bits = key return bits - def __getitem__(self, key): + def __getitem__(self, key) -> int: return self.variable.od.decode_bits(self.raw, self._get_bits(key)) - def __setitem__(self, key, value): + def __setitem__(self, key, value: int): self.raw = self.variable.od.encode_bits( self.raw, self._get_bits(key), value) self.write() diff --git a/doc/conf.py b/doc/conf.py index 2a1bd192..3f865400 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -33,6 +33,7 @@ extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', + 'sphinx_autodoc_typehints', 'sphinx.ext.viewcode', ] diff --git a/doc/profiles.rst b/doc/profiles.rst index dac3e85d..1ef5ab58 100644 --- a/doc/profiles.rst +++ b/doc/profiles.rst @@ -34,10 +34,13 @@ The current status can be read from the device by reading the register 0x6041, which is called the "Statusword". Changes in state can only be done in the 'OPERATIONAL' state of the NmtMaster -TPDO1 needs to be set up correctly. For this, run the the -`BaseNode402.setup_402_state_machine()` method. Note that this setup -routine will change only TPDO1 and automatically go to the 'OPERATIONAL' state -of the NmtMaster:: +PDOs with the Controlword and Statusword mapped need to be set up correctly, +which is the default configuration of most DS402-compatible drives. To make +them accessible to the state machine implementation, run the the +`BaseNode402.setup_402_state_machine()` method. Note that this setup routine +will read the current PDO configuration by default, causing some SDO traffic. +That works only in the 'OPERATIONAL' or 'PRE-OPERATIONAL' states of the +:class:`NmtMaster`:: # run the setup routine for TPDO1 and it's callback some_node.setup_402_state_machine() @@ -50,21 +53,20 @@ Write Controlword and read Statusword:: # Read the state of the Statusword some_node.sdo[0x6041].raw -During operation the state can change to states which cannot be commanded -by the Controlword, for example a 'FAULT' state. -Therefore the :class:`PowerStateMachine` class (in similarity to the :class:`NmtMaster` -class) automatically monitors state changes of the Statusword which is sent -by TPDO1. The available callback on thet TPDO1 will then extract the -information and mirror the state change in the :attr:`BaseNode402.powerstate_402` -attribute. +During operation the state can change to states which cannot be commanded by the +Controlword, for example a 'FAULT' state. Therefore the :class:`BaseNode402` +class (in similarity to :class:`NmtMaster`) automatically monitors state changes +of the Statusword which is sent by TPDO. The available callback on that TPDO +will then extract the information and mirror the state change in the +:attr:`BaseNode402.state` attribute. Similar to the :class:`NmtMaster` class, the states of the :class:`BaseNode402` -class :attr:`._state` attribute can be read and set (command) by a string:: +class :attr:`.state` attribute can be read and set (command) by a string:: # command a state (an SDO message will be called) - some_node.powerstate_402.state = 'SWITCHED ON' + some_node.state = 'SWITCHED ON' # read the current state - some_node.powerstate_402.state = 'SWITCHED ON' + some_node.state = 'SWITCHED ON' Available states: @@ -85,3 +87,10 @@ Available commands - 'SWITCHED ON' - 'OPERATION ENABLED' - 'QUICK STOP ACTIVE' + + +API +``` + +.. autoclass:: canopen.profiles.p402.BaseNode402 + :members: diff --git a/makedeb b/makedeb index c7da516d..591ffc11 100755 --- a/makedeb +++ b/makedeb @@ -1,11 +1,10 @@ #!/bin/sh py=python3 -name=canopen -pkgname=$py-canopen +name='canopen' +pkgname=$py-$name description="CANopen stack implementation" - version=`git tag |grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' |sort | tail -1 ` maintainer=`git log -1 --pretty=format:'%an <%ae>'` arch=all @@ -19,14 +18,13 @@ fakeroot=$package_dir mkdir -p $fakeroot -$py setup.py bdist >setup_py.log - -tar -f dist/*.tar.* -C $fakeroot -x +$py setup.py bdist_wheel >setup_py.log mkdir -p $fakeroot/usr/lib/$py/dist-packages/ -mv -f $(find $fakeroot -name $name -type d) $fakeroot/usr/lib/python3/dist-packages/ +unzip dist/*.whl -d $fakeroot/usr/lib/python3/dist-packages/ -cp -r $name.egg-info $fakeroot/usr/lib/python3/dist-packages/$name-$version.egg-info +# deploy extra files +#cp -r install/* $fakeroot/ mkdir $package_dir/DEBIAN diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..403805fb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +write_to = "canopen/_version.py" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..ca6253b5 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,23 @@ +[metadata] +name = canopen +description = CANopen stack implementation +long_description = file: README.rst +project_urls = + Documentation = http://canopen.readthedocs.io/en/stable/ + Source Code = https://github.com/christiansandberg/canopen +author = Christian Sandberg +author_email = christiansandberg@me.com +classifier = + Development Status :: 5 - Production/Stable + License :: OSI Approved :: MIT License + Operating System :: OS Independent + Programming Language :: Python :: 3 :: Only + Intended Audience :: Developers + Topic :: Scientific/Engineering + +[options] +packages = find: +python_requires = >=3.6 +install_requires = + python-can >= 3.0.0 +include_package_data = True diff --git a/setup.py b/setup.py index eb965308..60684932 100644 --- a/setup.py +++ b/setup.py @@ -1,33 +1,3 @@ -from setuptools import setup, find_packages +from setuptools import setup -description = open("README.rst").read() -# Change links to stable documentation -description = description.replace("/latest/", "/stable/") - -setup( - name="canopen", - url="https://github.com/christiansandberg/canopen", - use_scm_version=True, - packages=find_packages(), - author="Christian Sandberg", - author_email="christiansandberg@me.com", - description="CANopen stack implementation", - keywords="CAN CANopen", - long_description=description, - license="MIT", - platforms=["any"], - classifiers=[ - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Intended Audience :: Developers", - "Topic :: Scientific/Engineering" - ], - install_requires=["python-can>=3.0.0"], - extras_require={ - "db_export": ["canmatrix"] - }, - setup_requires=["setuptools_scm"], - include_package_data=True -) +setup() diff --git a/test/sample.eds b/test/sample.eds index 11c9c404..b01a9ee5 100644 --- a/test/sample.eds +++ b/test/sample.eds @@ -20,7 +20,7 @@ BaudRate_500=1 BaudRate_800=0 BaudRate_1000=1 SimpleBootUpMaster=0 -SimpleBootUpSlave=0 +SimpleBootUpSlave=1 Granularity=8 DynamicChannelsSupported=0 CompactPDO=0 @@ -46,7 +46,10 @@ Dummy0006=0 Dummy0007=0 [Comments] -Lines=0 +Lines=3 +Line1=|-------------| +Line2=| Don't panic | +Line3=|-------------| [MandatoryObjects] SupportedObjects=3 diff --git a/test/test_eds.py b/test/test_eds.py index a308d729..e5f6c89e 100644 --- a/test/test_eds.py +++ b/test/test_eds.py @@ -4,7 +4,6 @@ EDS_PATH = os.path.join(os.path.dirname(__file__), 'sample.eds') - class TestEDS(unittest.TestCase): def setUp(self): @@ -27,6 +26,12 @@ def test_variable(self): self.assertEqual(var.data_type, canopen.objectdictionary.UNSIGNED16) self.assertEqual(var.access_type, 'rw') self.assertEqual(var.default, 0) + self.assertFalse(var.relative) + + def test_relative_variable(self): + var = self.od['Receive PDO 0 Communication Parameter']['COB-ID use by RPDO 1'] + self.assertTrue(var.relative) + self.assertEqual(var.default, 512 + self.od.node_id) def test_record(self): record = self.od['Identity object'] @@ -90,3 +95,76 @@ def test_dummy_variable(self): def test_dummy_variable_undefined(self): with self.assertRaises(KeyError): var_undef = self.od['Dummy0001'] + + def test_comments(self): + self.assertEqual(self.od.comments, +""" +|-------------| +| Don't panic | +|-------------| +""".strip() + ) + + + def test_export_eds(self): + import tempfile + for doctype in {"eds", "dcf"}: + with tempfile.NamedTemporaryFile(suffix="."+doctype, mode="w+") as tempeds: + print("exporting %s to " % doctype + tempeds.name) + canopen.export_od(self.od, tempeds, doc_type=doctype) + tempeds.flush() + exported_od = canopen.import_od(tempeds.name) + + for index in exported_od: + self.assertIn(exported_od[index].name, self.od) + self.assertIn(index , self.od) + + for index in self.od: + if index < 0x0008: + # ignore dummies + continue + self.assertIn(self.od[index].name, exported_od) + self.assertIn(index , exported_od) + + actual_object = exported_od[index] + expected_object = self.od[index] + self.assertEqual(type(actual_object), type(expected_object)) + self.assertEqual(actual_object.name, expected_object.name) + + if type(actual_object) is canopen.objectdictionary.Variable: + expected_vars = [expected_object] + actual_vars = [actual_object ] + else : + expected_vars = [expected_object[idx] for idx in expected_object] + actual_vars = [actual_object [idx] for idx in actual_object] + + for prop in [ + "allowed_baudrates", + "vendor_name", + "vendor_number", + "product_name", + "product_number", + "revision_number", + "order_code", + "simple_boot_up_master", + "simple_boot_up_slave", + "granularity", + "dynamic_channels_supported", + "group_messaging", + "nr_of_RXPDO", + "nr_of_TXPDO", + "LSS_supported", + ]: + self.assertEqual(getattr(self.od.device_information, prop), getattr(exported_od.device_information, prop), f"prop {prop!r} mismatch on DeviceInfo") + + + for evar,avar in zip(expected_vars,actual_vars): + self. assertEqual(getattr(avar, "data_type" , None) , getattr(evar,"data_type" ,None) , " mismatch on %04X:%X"%(evar.index, evar.subindex)) + self. assertEqual(getattr(avar, "default_raw", None) , getattr(evar,"default_raw",None) , " mismatch on %04X:%X"%(evar.index, evar.subindex)) + self. assertEqual(getattr(avar, "min" , None) , getattr(evar,"min" ,None) , " mismatch on %04X:%X"%(evar.index, evar.subindex)) + self. assertEqual(getattr(avar, "max" , None) , getattr(evar,"max" ,None) , " mismatch on %04X:%X"%(evar.index, evar.subindex)) + if doctype == "dcf": + self.assertEqual(getattr(avar, "value" , None) , getattr(evar,"value" ,None) , " mismatch on %04X:%X"%(evar.index, evar.subindex)) + + self.assertEqual(self.od.comments, exported_od.comments) +