import os
import re
from abc import ABC
from typing import Optional, Union, overload
from xml.dom import minidom
from xml.etree import ElementTree
import ingenialogger
import numpy as np
from typing_extensions import Literal
from ingenialink import RegAccess, RegDtype
from ingenialink.dictionary import (
ACCESS_XDF_OPTIONS,
DTYPE_XDF_OPTIONS,
DictionaryV3,
Interface,
XMLBase,
)
from ingenialink.enums.register import RegAddressType
from ingenialink.exceptions import ILConfigurationFileParseError
from ingenialink.register import Register
@overload
def _get_attribute_from_element(
element: ElementTree.Element,
attribute: str,
*,
optional: Literal[False] = ...,
context: str = ...,
kind: str = ...,
) -> str: ...
@overload
def _get_attribute_from_element(
element: ElementTree.Element,
attribute: str,
*,
optional: Literal[True],
context: str = ...,
kind: str = ...,
) -> Optional[str]: ...
def _get_attribute_from_element(
element: ElementTree.Element,
attribute: str,
*,
optional: bool = False,
context: str = "",
kind: str = "",
) -> Optional[str]:
"""Get attribute value from XML element.
Args:
element: XML element.
attribute: attribute name.
optional: If True, return None if attribute not found. Else, raise ValueError.
context: Context string for error messages (e.g., UID).
Only used for error messages.
kind: Kind of element (e.g., 'register', 'table', 'table element').
Only used for error messages.
Returns:
attribute value.
Raises:
ValueError: attribute not found in element.
"""
attr_value = element.attrib.get(attribute)
if attr_value is not None:
return attr_value
if optional:
return None
error_msg = f"Missing {attribute} attribute"
if kind:
error_msg += f" in {kind}"
if context:
error_msg += f" for {context}"
raise ValueError(error_msg)
logger = ingenialogger.get_logger(__name__)
_INTERFACE_XCF_OPTIONS: dict[str, Interface] = {
"CAN": Interface.CAN,
"ECAT": Interface.ECAT,
"EoE": Interface.EoE,
"ETH": Interface.ETH,
}
[docs]
class Device:
"""Device data for ConfigurationFile (XCF) class."""
interface: Interface
part_number: Optional[str]
product_code: Optional[int]
revision_number: Optional[int]
firmware_version: Optional[str]
node_id: Optional[int] = None
_ELEMENT_NAME = "Device"
__INTERFACE_ATTR = "Interface"
__FW_VERSION_ATTR = "firmwareVersion"
__PRODUCT_CODE_ATTR = "ProductCode"
__PART_NUMBER_ATTR = "PartNumber"
__REVISION_NUMBER_ATTR = "RevisionNumber"
__NODE_ID_ATTR = "NodeID"
def __init__(
self,
interface: Interface,
part_number: Optional[str],
product_code: Optional[int],
revision_number: Optional[int],
firmware_version: Optional[str],
node_id: Optional[int] = None,
):
if interface != Interface.CAN and node_id is not None:
raise NotImplementedError("node_id field is only supported by CANopen devices")
self.interface = interface
self.part_number = part_number
self.product_code = product_code
self.revision_number = revision_number
self.firmware_version = firmware_version
self.node_id = node_id
self.__interface_value_to_str = {
value: key for key, value in _INTERFACE_XCF_OPTIONS.items()
}
[docs]
@classmethod
def from_xcf(cls, element: ElementTree.Element) -> "Device":
"""Creates a Device instance from XML element.
Returns:
Device instance filled with XML element data.
"""
interface = _INTERFACE_XCF_OPTIONS[element.attrib[cls.__INTERFACE_ATTR]]
part_number = element.attrib.get(cls.__PART_NUMBER_ATTR)
product_code_raw = element.attrib.get(cls.__PRODUCT_CODE_ATTR)
product_code = int(product_code_raw) if product_code_raw else None
revision_number_raw = element.attrib.get(cls.__REVISION_NUMBER_ATTR)
revision_number = int(revision_number_raw) if revision_number_raw else None
firmware_version = element.attrib.get(cls.__FW_VERSION_ATTR)
node_id_raw = element.attrib.get(cls.__NODE_ID_ATTR)
node_id = int(node_id_raw) if node_id_raw else None
return cls(interface, part_number, product_code, revision_number, firmware_version, node_id)
[docs]
def to_xcf(self) -> ElementTree.Element:
"""Creates an XML element with class data.
Returns:
XML element filled with class data
"""
register_xml = ElementTree.Element(self._ELEMENT_NAME)
register_xml.set(self.__INTERFACE_ATTR, self.__interface_value_to_str[self.interface])
if self.firmware_version is not None:
register_xml.set(self.__FW_VERSION_ATTR, self.firmware_version)
if self.product_code is not None:
register_xml.set(self.__PRODUCT_CODE_ATTR, str(self.product_code))
if self.part_number is not None:
register_xml.set(self.__PART_NUMBER_ATTR, self.part_number)
if self.revision_number is not None:
register_xml.set(self.__REVISION_NUMBER_ATTR, str(self.revision_number))
if self.node_id is not None:
register_xml.set(self.__NODE_ID_ATTR, str(self.node_id))
return register_xml
[docs]
class ConfigRegister:
"""Register class for ConfigurationFile (XCF) class."""
_ELEMENT_NAME = "Register"
__ACCESS_ATTR = "access"
__DTYPE_ATTR = "dtype"
__ID_ATTR = "id"
__SUBNODE_ATTR = "subnode"
__STORAGE_ATTR = "storage"
__DATA_ATTR = "data"
def __init__(
self,
uid: str,
subnode: int,
dtype: RegDtype,
access: RegAccess,
storage: Union[float, int, str, bool],
data: Optional[bytes] = None,
):
self.uid = uid
self.subnode = subnode
self.dtype = dtype
self.access = access
self.storage = storage
self.data = data
self.__access_value_to_str = {value: key for key, value in ACCESS_XDF_OPTIONS.items()}
self.__dtype_value_to_str = {value: key for key, value in DTYPE_XDF_OPTIONS.items()}
[docs]
@classmethod
def from_xcf(cls, element: ElementTree.Element) -> "ConfigRegister":
"""Creates a register from register XML element.
Returns:
ConfigRegister filled with XML element data
"""
uid = _get_attribute_from_element(element, cls.__ID_ATTR, kind="register")
subnode_str = _get_attribute_from_element(
element, cls.__SUBNODE_ATTR, context=uid, kind="register"
)
subnode = int(subnode_str)
dtype_str = _get_attribute_from_element(
element, cls.__DTYPE_ATTR, context=uid, kind="register"
)
dtype = DTYPE_XDF_OPTIONS[dtype_str]
access_str = _get_attribute_from_element(
element, cls.__ACCESS_ATTR, context=uid, kind="register"
)
access = ACCESS_XDF_OPTIONS[access_str]
storage_str = _get_attribute_from_element(
element, cls.__STORAGE_ATTR, context=uid, kind="register"
)
storage: Union[float, int, str, bool]
if dtype == RegDtype.FLOAT:
storage = float(storage_str)
elif dtype in [
RegDtype.S8,
RegDtype.U8,
RegDtype.S16,
RegDtype.U16,
RegDtype.S32,
RegDtype.U32,
RegDtype.S64,
RegDtype.U64,
]:
storage = int(storage_str)
elif dtype == RegDtype.STR:
storage = storage_str
elif dtype == RegDtype.BOOL:
storage = bool(storage_str)
else:
raise NotImplementedError(f"dtype {dtype} not implemented for register {uid}.")
data_attr = _get_attribute_from_element(
element, cls.__DATA_ATTR, optional=True, context=uid, kind="register"
)
data: Optional[bytes] = bytes.fromhex(data_attr) if data_attr is not None else None
return cls(uid, subnode, dtype, access, storage, data=data)
[docs]
@classmethod
def from_register(
cls, register: Register, value: Union[float, int, str, bool]
) -> "ConfigRegister":
"""Creates a ConfigRegister from a register, and its value.
Returns:
ConfigRegister instance filled with register data
Raises:
ValueError: Register has no an identifier
"""
if register.identifier is None:
raise ValueError("register has not an identifier")
return cls(register.identifier, register.subnode, register.dtype, register.access, value)
[docs]
def to_xcf(self) -> ElementTree.Element:
"""Creates a XML element from class data.
Returns:
XML register element filled with class data
"""
register_xml = ElementTree.Element(self._ELEMENT_NAME)
register_xml.set(self.__ACCESS_ATTR, self.__access_value_to_str[self.access])
register_xml.set(self.__DTYPE_ATTR, self.__dtype_value_to_str[self.dtype])
register_xml.set(self.__ID_ATTR, self.uid)
register_xml.set(self.__SUBNODE_ATTR, str(self.subnode))
if isinstance(self.storage, float):
register_xml.set(self.__STORAGE_ATTR, str(np.float32(self.storage)))
else:
register_xml.set(self.__STORAGE_ATTR, str(self.storage))
# Serialize 'data' attribute (stored as bytes) if present
if self.data is not None:
register_xml.set(self.__DATA_ATTR, self.data.hex())
return register_xml
[docs]
class TableElement:
"""Table element for ConfigTable class.
Represents a single element value stored in a position of a table.
"""
_ELEMENT_NAME = "Element"
__ADDRESS_ATTR = "address"
__DATA_ATTR = "data"
def __init__(self, address: int, data: bytes):
"""Initialize a table element.
Args:
address: Element address/index.
data: Element data as bytes.
"""
self.address = address
self.data = data
[docs]
@classmethod
def from_xcf(cls, element: ElementTree.Element, table_uid: str = "") -> "TableElement":
"""Creates a TableElement from XML element.
Args:
element: XML Element element.
table_uid: Parent table UID for error messages.
Returns:
TableElement instance filled with XML element data.
Raises:
ValueError: Missing required attribute.
"""
address_str = _get_attribute_from_element(
element, cls.__ADDRESS_ATTR, context=table_uid, kind="table element"
)
address = int(address_str)
data_str = _get_attribute_from_element(
element, cls.__DATA_ATTR, context=table_uid, kind="table element"
)
try:
data = bytes.fromhex(data_str)
except ValueError as exc:
context = f" in table '{table_uid}'" if table_uid else ""
raise ValueError(
f"Invalid hexadecimal data '{data_str}' for attribute '{cls.__DATA_ATTR}'{context}"
) from exc
return cls(address, data)
[docs]
def to_xcf(self) -> ElementTree.Element:
"""Creates an XML element with class data.
Returns:
XML element filled with class data.
"""
element_xml = ElementTree.Element(self._ELEMENT_NAME)
element_xml.set(self.__ADDRESS_ATTR, str(self.address))
element_xml.set(self.__DATA_ATTR, self.data.hex())
return element_xml
[docs]
class ConfigTable:
"""Table class for ConfigurationFile (XCF) class."""
_ELEMENT_NAME = "Table"
__ID_ATTR = "id"
__SUBNODE_ATTR = "subnode"
def __init__(self, uid: str, subnode: int, elements: Optional[list[TableElement]] = None):
"""Initialize a ConfigTable.
Args:
uid: Table unique identifier.
subnode: Subnode/axis number.
elements: List of table elements.
"""
self.uid = uid
self.subnode = subnode
self.elements = elements if elements is not None else []
[docs]
@classmethod
def from_xcf(cls, element: ElementTree.Element) -> "ConfigTable":
"""Creates a ConfigTable from XML element.
Args:
element: XML Table element.
Returns:
ConfigTable instance filled with XML element data.
Raises:
ValueError: Missing required attribute.
"""
uid = _get_attribute_from_element(element, cls.__ID_ATTR, kind="table")
subnode_str = _get_attribute_from_element(
element, cls.__SUBNODE_ATTR, context=uid, kind="table"
)
subnode = int(subnode_str)
elements: list[TableElement] = []
for elem in element.findall(TableElement._ELEMENT_NAME):
try:
elements.append(TableElement.from_xcf(elem, uid))
except ValueError as e: # noqa: PERF203
logger.warning(f"Cannot load table element for table {uid}: {e}")
return cls(uid, subnode, elements)
[docs]
def to_xcf(self) -> ElementTree.Element:
"""Creates an XML element with class data.
Returns:
XML table element filled with class data.
"""
table_xml = ElementTree.Element(self._ELEMENT_NAME)
table_xml.set(self.__ID_ATTR, self.uid)
table_xml.set(self.__SUBNODE_ATTR, str(self.subnode))
for element in self.elements:
table_xml.append(element.to_xcf())
return table_xml
[docs]
class ConfigurationFile(XMLBase, ABC):
"""Configuration file (XCF) class.
It can be generated from a XCF file or be exported to a XCF file.
"""
_VERSION_REGEX = r"(\d+)\.*(\d*)"
_MAJOR_VERSION_GROUP = 1
_MINOR_VERSION_GROUP = 2
_CHECK_FAIL_EXCEPTION = ILConfigurationFileParseError
__ROOT_ELEMENT = "IngeniaDictionary"
__HEADER_ELEMENT = "Header"
__VERSION_ELEMENT = "Version"
__BODY_ELEMENT = "Body"
__REGISTERS_ELEMENT = "Registers"
__TABLES_ELEMENT = "Tables"
_SUPPORTED_MAJOR_VERSION = 2
def __init__(self, device: Device) -> None:
self.major_version = self._SUPPORTED_MAJOR_VERSION
self.minor_version = 2
self.__registers: list[ConfigRegister] = []
self.__tables: list[ConfigTable] = []
self.__device: Device = device
self.__subnodes: set[int] = set()
@classmethod
def __read_version(cls, version_element: ElementTree.Element) -> tuple[int, int]:
"""Process Version element and set version.
Args:
version_element: Version element
Returns:
major and minor version
Raises:
ILConfigurationFileParseError: version is empty
"""
if version_element.text is None:
raise ILConfigurationFileParseError("Version is empty")
version_str = version_element.text.strip()
version_match = re.match(cls._VERSION_REGEX, version_str)
if version_match is None:
raise ILConfigurationFileParseError("Version has a wrong format")
major_version = int(version_match.group(cls._MAJOR_VERSION_GROUP))
if version_match.group(cls._MINOR_VERSION_GROUP):
minor_version = int(version_match.group(cls._MINOR_VERSION_GROUP))
else:
minor_version = 0
return major_version, minor_version
[docs]
@classmethod
def load_from_xcf(cls, xcf_path: str) -> "ConfigurationFile":
"""Creates a XCF instance from a XCF file.
Args:
xcf_path: XCF file path
Returns:
XCF instance with XCF file data
Raises:
FileNotFoundError: xcf_path file not found
NotImplementedError: Configuration file version not supported
"""
if not os.path.isfile(xcf_path):
raise FileNotFoundError(f"Could not find {xcf_path}.")
with open(xcf_path, encoding="utf-8") as xml_file:
tree = ElementTree.parse(xml_file)
root = tree.getroot()
header = cls._find_and_check(root, cls.__HEADER_ELEMENT)
version = cls._find_and_check(header, cls.__VERSION_ELEMENT)
major_version, minor_version = cls.__read_version(version)
if major_version != cls._SUPPORTED_MAJOR_VERSION:
raise NotImplementedError(
f"Configuration file not supported: "
f"Supported version: {cls._SUPPORTED_MAJOR_VERSION}, "
f"File version: {major_version}"
)
body = cls._find_and_check(root, cls.__BODY_ELEMENT)
device_element = cls._find_and_check(body, Device._ELEMENT_NAME)
registers_element = cls._find_and_check(device_element, cls.__REGISTERS_ELEMENT)
register_element_list = cls._findall_and_check(
registers_element, ConfigRegister._ELEMENT_NAME
)
config_device = Device.from_xcf(device_element)
xcf_instance = cls(config_device)
for reg in register_element_list:
try:
xcf_instance.add_config_register(ConfigRegister.from_xcf(reg))
except (ValueError, KeyError, NotImplementedError) as e: # noqa: PERF203
logger.warning(f"Cannot load register. {e}")
# Load tables if present (optional element)
tables_element = device_element.find(cls.__TABLES_ELEMENT)
if tables_element is not None:
table_element_list = tables_element.findall(ConfigTable._ELEMENT_NAME)
for table_elem in table_element_list:
try:
xcf_instance.add_config_table(ConfigTable.from_xcf(table_elem))
except (ValueError, KeyError) as e: # noqa: PERF203
logger.warning(f"Cannot load table. {e}")
xcf_instance.major_version = major_version
xcf_instance.minor_version = minor_version
return xcf_instance
[docs]
@classmethod
def create_empty_configuration(
cls,
interface: Interface,
part_number: Optional[str],
product_code: Optional[int],
revision_number: Optional[int],
firmware_version: Optional[str],
node_id: Optional[int] = None,
) -> "ConfigurationFile":
"""Create an empty XCF with the device info.
Args:
interface: drive interface
part_number: drive part number
product_code: drive product code
revision_number: drive firmware revision number
firmware_version: drive firmware version
node_id: drive node id. Only for CANopen drives
Returns:
creates a XCF instance
"""
device = Device(
interface, part_number, product_code, revision_number, firmware_version, node_id
)
xcf_instance = cls(device)
return xcf_instance
[docs]
def add_register(self, register: Register, value: Union[float, int, str, bool]) -> None:
"""Add register to the XCF class.
Args:
register: register that will be added to the XCF
value: value that will be added to the register in te XCF
"""
config_register = ConfigRegister.from_register(register, value)
self.add_config_register(config_register)
[docs]
def add_config_register(self, config_register: ConfigRegister) -> None:
"""Add ConfigRegister to the XCF class.
Args:
config_register: register that will be added to the XCF
"""
self.__registers.append(config_register)
self.__subnodes.add(config_register.subnode)
[docs]
def add_config_table(self, config_table: ConfigTable) -> None:
"""Add ConfigTable to the XCF class.
Args:
config_table: table that will be added to the XCF
"""
self.__tables.append(config_table)
self.__subnodes.add(config_table.subnode)
[docs]
def save_to_xcf(self, xcf_path: str) -> None:
"""Save a file with the config file in the target path.
Args:
xcf_path: config file target path
Raises:
ValueError: the configuration has no registers
"""
if not self.registers:
raise ValueError("registers is empty")
tree = ElementTree.Element(self.__ROOT_ELEMENT)
header = ElementTree.SubElement(tree, self.__HEADER_ELEMENT)
body = ElementTree.SubElement(tree, self.__BODY_ELEMENT)
version = ElementTree.SubElement(header, self.__VERSION_ELEMENT)
version.text = self.version
device = self.device.to_xcf()
registers_element = ElementTree.SubElement(device, self.__REGISTERS_ELEMENT)
for register in self.registers:
registers_element.append(register.to_xcf())
# Add tables if present
if self.tables:
tables_element = ElementTree.SubElement(device, self.__TABLES_ELEMENT)
for table in self.tables:
tables_element.append(table.to_xcf())
body.append(device)
dom = minidom.parseString(ElementTree.tostring(tree, encoding="utf-8"))
with open(xcf_path, "wb") as f:
f.write(dom.toprettyxml(indent="\t").encode())
@property
def version(self) -> str:
"""Version string."""
if self.minor_version != 0:
return f"{self.major_version}.{self.minor_version}"
else:
return f"{self.major_version}"
@property
def device(self) -> Device:
"""Configuration file device."""
return self.__device
@property
def registers(self) -> list[ConfigRegister]:
"""Configuration file registers."""
return self.__registers
@property
def tables(self) -> list[ConfigTable]:
"""Configuration file tables."""
return self.__tables
[docs]
def contains_node(self, subnode: int) -> bool:
"""Check of configuration file contains register of the target subnode.
Args:
subnode: target subnode number
Returns:
True if contains target subnode registers, else False
"""
return subnode in self.__subnodes
[docs]
@classmethod
def from_dictionary_defaults(cls, dictionary: DictionaryV3) -> "ConfigurationFile":
"""Create a ConfigurationFile populated with default values from an XDF3 dictionary.
Iterates all registers in the dictionary, filters to those that are writable
(RW) and stored in NVM (NVM_CFG or NVM address type), and populates each with
its declared default value.
Tables are not included in the generated ConfigurationFile, as they have no default
values declared in the dictionary.
Args:
dictionary: XDF3 dictionary to read defaults from.
Returns:
ConfigurationFile with all valid registers set to their default values.
Raises:
ValueError: If a qualifying register has no default value or a bytes default
(DOMAIN dtype), which indicates the dictionary is not a valid XDF3 or
the register default is missing.
"""
xcf_instance = cls.create_empty_configuration(
interface=dictionary.interface,
part_number=dictionary.part_number,
product_code=dictionary.product_code,
revision_number=dictionary.revision_number,
firmware_version=dictionary.firmware_version,
)
for register in dictionary.all_registers():
# Only NVM/NVM_CFG registers with RW access are stored in a configuration file
if register.access != RegAccess.RW or register.address_type not in (
RegAddressType.NVM_CFG,
RegAddressType.NVM,
):
continue
default = register.default
# A None or bytes default indicates the XDF3 is missing the default declaration,
# which is not allowed in a valid V3 dictionary
if default is None or isinstance(default, bytes):
raise ValueError(
f"Register {register.identifier!r} has no valid default value. "
"from_dictionary_defaults requires an XDF3 dictionary where all "
"NVM/NVM_CFG RW registers have a declared default value."
)
xcf_instance.add_register(register, default)
return xcf_instance
[docs]
def override_values(self, other: "ConfigurationFile") -> None:
"""Overlay another ConfigurationFile's values onto this one.
For each register and table in ``other``:
- If a matching entry (same subnode and uid) exists in ``self``, it is replaced.
- If no match is found the entry is appended to ``self``. For registers a warning
is logged (unexpected mismatch). For tables a debug message is logged instead,
because tables have no default values in the dictionary and are therefore never
present in a ``ConfigurationFile`` built with :meth:`from_dictionary_defaults`.
Args:
other: ConfigurationFile whose values will be applied on top of ``self``.
"""
# Build an index of existing registers keyed by (subnode, uid) for O(1) lookup
existing_reg_idx: dict[tuple[int, str], int] = {
(reg.subnode, reg.uid): i for i, reg in enumerate(self.registers)
}
for new_reg in other.registers:
key = (new_reg.subnode, new_reg.uid)
if key in existing_reg_idx:
# Replace the existing register with the overriding value
self.registers[existing_reg_idx[key]] = new_reg
else:
# Register is new (not present in base config) — add it but warn the caller
logger.warning(
f"Register {new_reg.uid!r} (subnode {new_reg.subnode}) from the override "
"configuration was not found in the target; it will be added."
)
self.add_config_register(new_reg)
# Same logic for tables, indexed by (subnode, uid)
existing_table_idx: dict[tuple[int, str], int] = {
(t.subnode, t.uid): i for i, t in enumerate(self.tables)
}
for new_table in other.tables:
key = (new_table.subnode, new_table.uid)
if key in existing_table_idx:
# Replace the existing table with the overriding content
self.tables[existing_table_idx[key]] = new_table
else:
# Table did not exist in base config — Probably from a older xcf
# or one that was created from a dictionary defaults (that does not include them)
logger.debug(
f"Table {new_table.uid!r} (subnode {new_table.subnode}) not in target; adding."
)
self.add_config_table(new_table)