Source code for ekfsm.devices.eeprom

"""
A module containing classes to represent EEPROM devices.

Routine Listings
----------------
    :py:class:`EEPROM`
    :py:class:`Validatable_EEPROM`
    :py:class:`EKF_EEPROM`
    :py:class:`validated`
"""

from abc import ABC, abstractmethod
from datetime import date
from typing import Any, Callable, Literal, Sequence
from functools import wraps

from ekfsm.core.components import SysTree
from ekfsm.core.probe import ProbeableDevice

from .generic import Device
from .utils import compute_int_from_bytes, get_crc16_xmodem
from ekfsm.exceptions import DataCorruptionError
from hexdump import hexdump

from ekfsm.log import ekfsm_logger

logger = ekfsm_logger(__name__)


[docs] def validated(func: Callable[..., Any]) -> Callable[..., Any]: """ A decorator to validate the CRC of the EEPROM content before executing a method. Parameters ---------- func The method to validate. Note ---- This decorator should be used on methods that read data from an EEPROM. """ @wraps(func) def validate(self, *args, **kwargs): logger.debug(f"Validating EEPROM content for {self.name}") if not self.valid: raise DataCorruptionError("CRC validation failed") logger.debug(f"EEPROM content is valid for {self.name}") return func(self, *args, **kwargs) return validate
[docs] class EEPROM(Device): """ A class used to represent a generic EEPROM device. Parameters ---------- name The name of the EEPROM device. parent The parent device of the EEPROM in the :py:class:`~.generic.Device` tree. Caution ------- The following conditions must be met for this class to work properly: - EEPROM must be I2C accessable - EEPROM must have a sysfs device Note ---- This class should be inherited by classes representing specific EEPROM devices and defining custom storage schemes. """ def __init__( self, name: str, parent: SysTree | None = None, *args, **kwargs, ): super().__init__(name, parent, None, *args, **kwargs) self.addr = self.get_i2c_chip_addr() self.sysfs_device = self.get_i2c_sysfs_device(self.addr) self._update_content()
[docs] def _update_content(self) -> None: """ Update the content of the EEPROM device. Note ---- - This method should be called whenever the content of the EEPROM is updated (after each write op). - Inheriting classes should call this method before updating their own attributes. """ logger.debug("Reading data") try: data = self.read() self._content = data except Exception as e: logger.error(f"Error reading data, {e}") self._content = b""
[docs] def read(self) -> bytes: """ Read the content of the EEPROM. Returns ------- The content of the EEPROM. Raises ------ FileNotFoundError If the EEPROM sysfs file is not found. RuntimeError If the sysfs device is not found. """ try: if self.sysfs_device: cnt = self.sysfs_device.read_attr_bytes("eeprom") else: raise RuntimeError("No sysfs device for EEPROM") except FileNotFoundError: raise FileNotFoundError("EEPROM not found") return cnt
[docs] def write(self, data: bytes, offset: int = 0) -> None: """ Write data to the EEPROM. Parameters ---------- data The data to write to the EEPROM. offset The offset at which to start writing the data. Raises ------ RuntimeError If the sysfs device is not found. FileNotFoundError If the EEPROM sysfs file is not found. DataCorruptionError If an error occurs during the write operation. Note ---- Operation is checked for data corruption by reading back the written data. """ try: if self.sysfs_device: logger.info(f"Writing {len(data)} bytes to EEPROM at offset {offset}") self.sysfs_device.write_attr("eeprom", data, offset) else: raise RuntimeError("No sysfs device for EEPROM") except FileNotFoundError: raise FileNotFoundError("EEPROM not found") self._update_content() written = self._content[offset : offset + len(data)] if not written == data: raise DataCorruptionError( "Error during EEPROM write, data is not the same as read back" )
[docs] def print(self): hexdump(self._content)
[docs] class Validatable_EEPROM(EEPROM, ABC): """ Abstract class used to represent an EEPROM device using CRC to validate its content. Parameters ---------- crc_pos The position of the CRC value in the EEPROM content (`'start'` or `'end'`). crc_length The length of the CRC value in number of bytes (defaults to 2). Note ---- - Derived classes must implement a method to compute the CRC value of the EEPROM content. - If the CRC position differs from the shipped schema `('start' | 'end')`, the derived class must override the :meth:`~Validatable_EEPROM._update_content` method. - Validity of individual content fields stored/returned by attributes, methods or properties can be achieved by using the :py:func:`validated` decorator. See Also -------- Validatable_EEPROM._compute_crc : Method to compute the CRC value of the EEPROM content. """ def __init__( self, crc_pos: Literal["start", "end"] = "end", crc_length: int = 2, *args, **kwargs, ) -> None: self._crc_length: int = crc_length self._crc_pos: str = crc_pos super().__init__(*args, **kwargs) self._crc_pos_start = len(self._data) if self._crc_pos == "end" else 0 self._crc_pos_end = self._crc_pos_start + self._crc_length
[docs] def _update_content(self) -> None: """ Update the content of the EEPROM device (checksum excluded). """ super()._update_content() # Firmware data without CRC self._data: bytes = ( self._content[self._crc_length :] if self._crc_pos == "start" else self._content[: -self._crc_length :] )
[docs] def _update_crc(self) -> None: """ Update the CRC value of the EEPROM content. """ self.crc = self._compute_crc()
def _get_crc_value(self) -> int: return int.from_bytes( self._content[self._crc_pos_start : self._crc_pos_end], byteorder="little" ) @property def crc(self) -> int: """ Gets or sets the CRC value of the EEPROM content. Parameters ---------- value: optional The CRC value to write to the EEPROM. Returns ------- int The CRC value currently stored in the EEPROM if used as *getter*. None If used as *setter*. Caution ------- The *setter* actually writes the CRC value to the EEPROM. Warning ------- The *setter* method should be used with caution as it can lead to data corruption if the CRC value is not correct! Note ---- The *setter* is usually triggered automatically after a successful write operation and in most cases, there is no need to call it manually. """ return self._get_crc_value() @crc.setter def crc(self, value: int) -> None: crc_bytes = value.to_bytes(self._crc_length, byteorder="little") logger.debug(f"Writing CRC value {value} to EEPROM") try: super().write(crc_bytes, self._crc_pos_start) except Exception as e: logger.error(f"Error writing CRC value to EEPROM, {e}") # self._update_content() @property def valid(self) -> bool: """ Checks if the EEPROM content is valid by comparing the stored CRC value with the computed CRC value. Returns ------- bool `True` if the EEPROM content is valid, `False` otherwise. """ return self._compute_crc() == self.crc
[docs] @abstractmethod def _compute_crc(self) -> int: """ This method should be implemented by derived classes to compute the CRC value of the EEPROM content. Returns ------- The computed CRC value. """ pass
[docs] def write(self, data: bytes, offset: int = 0) -> None: try: super().write(data, offset) self._update_crc() except Exception as e: logger.error(f"Error writing to EEPROM, {e}")
[docs] class EKF_EEPROM(Validatable_EEPROM, ProbeableDevice): """ A class used to represent an EKF EEPROM device. Structure --------- The EKF_EEPROM content is structured as follows: - `Serial number` (4 bytes, starts at pos 8): The serial number of the device. - `Manufactured at` (2 bytes, starts at pos 12): The date the device was manufactured. - `Repaired at` (2 bytes, starts at pos 14): The date the device was repaired. - `Customer serial number` (4 bytes, starts at pos 32): The customer serial number of the device. - `Customer configuration block offset pointer` (4 bytes, starts at pos 36): The offset pointer to the customer configuration block. - `String array` (78 bytes, starts at pos 48): An array of strings containing the model, manufacturer, and custom board data of the device. - `CRC` (2 bytes, starts at pos 126): The CRC value of the EEPROM content. - `Raw content` (80 bytes, starts at pos 128): Free customizable content for other purposes. Note ---- - As the CRC value is stored at the end of the OEM data space, just before the customer configuration block, the CRC position is manually set and the :meth:`~ekfsm.devices.eeprom.Validatable_EEPROM._update_content` method is overridden. - The CRC value is computed using the `CRC-16/XMODEM <https://en.wikipedia.org/wiki/Cyclic_redundancy_check>`_ algorithm. - Dates are stored in a proprietary format (2 bytes) and must be decoded using the :meth:`~EKF_EEPROM._decode_date` method. See Also -------- `crcmod <https://crcmod.sourceforge.net/>`_ Important --------- All data read from the EEPROM should be validated using the @validated decorator. This decorator ensures that the data is not corrupted by checking the CRC value. Raises ------ DataCorruptionError If the CRC validation fails. """ _sernum_index_start = 8 _sernum_index_end = 12 _date_mft_index_start = 12 _date_mft_index_end = 14 _date_rep_index_start = 14 _date_rep_index_end = 16 _customer_serial_index_start = 32 _customer_serial_index_end = 36 _customer_config_block_offset_pointer_index = 36 _str_array_start_offset = 48 _str_array_end_offset = 126 def __init__( self, *args, **kwargs, ) -> None: super().__init__(*args, **kwargs) def _update_content(self) -> None: super()._update_content() # EKF EEPROM content is restricted to 128 bytes, so strip the rest! self._firmware_content = self._content[:128] # The rest is raw content available for other purposes self._raw_content: bytes = self._content[128:] # Firmware data without CRC needs to be overriden self._data: bytes = ( self._firmware_content[self._crc_length :] if self._crc_pos == "start" else self._firmware_content[: -self._crc_length :] ) self._str_list = self._get_string_array() self._crc_pos_start = len(self._data) self._crc_pos_end = self._crc_pos_start + self._crc_length
[docs] @validated def serial(self) -> str: """ Get the serial number of the device to which the EEPROM is attached (the root device). Returns ------- The serial number of the root device. """ area = self._content[self._sernum_index_start : self._sernum_index_end] sernum = compute_int_from_bytes(area[::-1]) return str(sernum)
[docs] def write_serial(self, serial: int) -> None: """ Write serial number of the root device to EEPROM. Parameters ---------- serial The serial number to write to the EEPROM. """ # Check serial number is within bounds unsigned_upper_bound = (2**32) - 1 if serial < 0 or serial > unsigned_upper_bound: raise ValueError( f"Serial number must be between 0 and {unsigned_upper_bound}" ) serial_bytes = serial.to_bytes(4, byteorder="little") self.write(serial_bytes, self._sernum_index_start)
[docs] @validated def custom_serial(self) -> str: """ Get the customer serial number of the device to which the EEPROM is attached (the root device). Attention --------- This is a custom - non-OEM - serial number that can be set by the user. Returns ------- The customer serial number of the root device. """ area = self._content[ self._customer_serial_index_start : self._customer_serial_index_end ] sernum = compute_int_from_bytes(area[::-1]) return str(sernum)
[docs] def write_custom_serial(self, serial: int) -> None: """ Write customer serial number of the root device to EEPROM. Parameters ---------- serial The customer serial number to write to the EEPROM. Raises ------ ValueError If the serial number is not within the bounds of a 32-bit unsigned integer. Note ---- Due to space restrictions on storage, the serial number must be a 32-bit unsigned integer. """ # Check serial number is within bounds unsigned_upper_bound = (2**32) - 1 if serial < 0 or serial > unsigned_upper_bound: raise ValueError( f"Serial number must be between 0 and {unsigned_upper_bound}" ) serial_bytes = serial.to_bytes(4, byteorder="little") logger.debug(f"Writing customer serial {serial}") self.write(serial_bytes, self._customer_serial_index_start)
[docs] @validated def manufactured_at(self) -> date: """ Get the date the device was manufactured. Returns ------- The date the device was manufactured. """ area = self._content[self._date_mft_index_start : self._date_mft_index_end] encoded_mft_date = area[::-1] return self._decode_date(encoded_mft_date)
[docs] @validated def repaired_at(self) -> date: """ Get the date the device was repaired. Returns ------- The most recent date the device was repaired. """ area = self._content[self._date_rep_index_start : self._date_rep_index_end] encoded_rep_date = area[::-1] return self._decode_date(encoded_rep_date)
[docs] @validated def write_repaired_at(self, date: date) -> None: """ Write the date the device was repaired to EEPROM. Parameters ---------- date The date the device was repaired. Note ---- The date year must be within the range of 1980-2079. Attention --------- The date is stored in a proprietary 2-byte format. Raises ------ ValueError If the year is not within the range of 1980-2079. """ if date.year < 1980 or date.year > 2079: raise ValueError("Year must be within the range of 1980-2079") rep_date_bytes = self._encode_date(date) logger.debug(f"Writing repair date {date} to EEPROM") self.write(rep_date_bytes, self._date_rep_index_start)
[docs] @validated def model(self) -> str | None: """ Get the model name of the device to which the EEPROM is attached to (the root device). Returns ------- The model name of the device. """ return self._str_list[0] if len(self._str_list) > 0 else None
[docs] @validated def vendor(self) -> str | None: """ Get the vendor/manufacturer of the device to which the EEPROM is attached to (the root device). Returns ------- The name of the vendor/manufacturer of the device. """ return self._str_list[1] if len(self._str_list) > 1 else None
[docs] @validated def custom_board_data(self) -> str | None: """ Get the custom board data of the device. Note ---- This is a custom field that can be set by the user. Attention --------- This field is optional and may not be present in the EEPROM content. Returns ------- The custom board data of the device as a string, or `None` if the field is not present. """ return None if len(self._str_list) < 3 else self._str_list[2]
[docs] def write_custom_board_data(self, data: str) -> None: """ Write custom board data to EEPROM. Important --------- Due to size limitations, the custom board data should only contain expressive, short content like serials, variants or specific codes. Parameters ---------- data The custom board data to write to the EEPROM. Attention --------- The model and vendor fields are mandatory and must be set before writing custom board data. Raises ------ ValueError If the model and vendor fields are not set before writing custom board data. """ data_bytes = data.encode("utf-8") data_offset = 0 for s in self._str_list[:2]: if s is None: raise ValueError( "Model and vendor fields must be set before writing custom board data" ) if isinstance(s, str): data_offset += len(s) + 1 logger.info(f"Writing custom board data {data} to EEPROM") self.write(data_bytes, self._str_array_start_offset + data_offset)
[docs] def custom_raw_data(self) -> bytes: """ Get the raw content area data stored in the EEPROM. Returns ------- The data contained in the raw content block of the EEPROM. Note ---- This area is free for custom data storage and is not included during crc calculations and validations. Important --------- If custom raw data should be stored on EEPROM and if it should be protected against corruption, it has to be validated manually. """ return self._raw_content
[docs] def write_custom_raw_data(self, data: bytes) -> None: """ Write custom data to the raw content area of the EEPROM. Parameters ---------- data The data to write to the raw content area of the EEPROM. """ logger.info(f"Writing {len(data)} bytes to raw content area of EEPROM") self.write(data, 128)
def _get_string_array(self) -> list[str | None]: str_array = self._content[ self._str_array_start_offset : self._str_array_end_offset ].split(b"\x00") try: return [s.decode("utf-8") for s in str_array if s] except UnicodeDecodeError: return [None, None, None]
[docs] @classmethod def _decode_date(cls, encoded_date: Sequence[int]) -> date: """ Decode a date from a proprietary 2-byte format. Parameters ---------- encoded_date The date to decode. Raises ------ ValueError If the date is invalid (e.g., 30th Feb). Returns ------- date The decoded date. """ encoded_date = encoded_date[::-1] bdate = compute_int_from_bytes(encoded_date) # Extract the day (bit 0-4) day = bdate & 0x1F # 0x1F is 00011111 in binary (5 bits) # Extract the month (bit 5-8) month = (bdate >> 5) & 0x0F # Shift right by 5 and mask with 0x0F (4 bits) # Extract the year since 1980 (bit 9-15) year = (bdate >> 9) & 0x7F # Shift right by 9 and mask with 0x7F (7 bits) year += 1980 # Add base year (1980) # Return a datetime object with the extracted year, month, and day try: decoded_date = date(year, month, day) return decoded_date except ValueError: raise ValueError( f"Invalid date: {day}/{month}/{year}" ) # Handle invalid dates, e.g., 30th Feb
[docs] @classmethod def _encode_date(self, date: date) -> bytes: """ Encode a date into a proprietary 2-byte format. Parameters ---------- date The date to encode. Returns ------- bytes The encoded date. """ year = date.year - 1980 month = date.month day = date.day encoded_date = year << 9 | month << 5 | day return encoded_date.to_bytes(2, byteorder="little")
def _compute_crc(self) -> int: return get_crc16_xmodem(self._data)
[docs] def probe(self, *args, **kwargs): return self.hw_module.id == self.model()
[docs] class EKF_CCU_EEPROM(EKF_EEPROM): """ EKF CCU EEPROM - uses the second part of the EEPROM for chassis inventory and customer area """ _cvendor_index_start = 128 _cvendor_length = 24 _cmodel_index_start = 152 _cmodel_length = 24 _crevision_index_start = 176 _crevision_length = 9 _cserial_index_start = 185 _cserial_length = 4 _unit_index_start = 189 _unit_length = 1 _customer_area_start = 190 _customer_area_length = 63 def __init__( self, *args, **kwargs, ): super().__init__(*args, **kwargs) def _update_content(self) -> None: super()._update_content() # CCU content is the raw content area of the EEPROM self._ccu_content: bytes = self._raw_content # CCU Firmware data without CRC needs to be overriden self._cdata: bytes = ( self._ccu_content[self._crc_length :] if self._crc_pos == "start" else self._ccu_content[: -self._crc_length :] ) self._ccrc_pos_start = len(self._cdata) + 128 self._ccrc_pos_end = self._ccrc_pos_start + self._crc_length
[docs] def _update_ccrc(self) -> None: """ Update the CRC value of the EEPROM content. """ self.ccrc = self._compute_ccrc()
def _get_ccrc_value(self) -> int: return int.from_bytes( self._content[self._ccrc_pos_start : self._ccrc_pos_end], byteorder="little" ) @property def ccrc(self) -> int: """ Gets or sets the CRC value of the EEPROM content. Parameters ---------- value: optional The CRC value to write to the EEPROM. Returns ------- int The CRC value currently stored in the EEPROM if used as *getter*. None If used as *setter*. Caution ------- The *setter* actually writes the CRC value to the EEPROM. Warning ------- The *setter* method should be used with caution as it can lead to data corruption if the CRC value is not correct! Note ---- The *setter* is usually triggered automatically after a successful write operation and in most cases, there is no need to call it manually. """ return self._get_ccrc_value() @ccrc.setter def ccrc(self, value: int) -> None: ccrc_bytes = value.to_bytes(self._crc_length, byteorder="little") logger.debug(f"Writing chassis CRC value {value}") try: super(Validatable_EEPROM, self).write(ccrc_bytes, self._ccrc_pos_start) except Exception as e: logger.error(f"Error writing CRC value, error: {e}") @property def valid(self) -> bool: """ Checks if the EEPROM content is valid by comparing the stored CRC value with the computed CRC value. Returns ------- bool `True` if the EEPROM content is valid, `False` otherwise. """ return self._compute_ccrc() == self.ccrc and super().valid
[docs] @validated def cvendor(self) -> str: """ Get the chassis vendor. Returns ------- The vendor of the chassis. """ return ( self._content[ self._cvendor_index_start : self._cvendor_index_start + self._cvendor_length ] .strip(b"\x00") .decode("utf-8") )
[docs] def write_cvendor(self, vendor: str) -> None: """ Write the vendor of the chassis to EEPROM. Parameters ---------- vendor The vendor of the chassis. """ vendor_bytes = vendor.encode("utf-8") vendor_fill = b"\x00" * (self._cvendor_length - len(vendor_bytes)) vendor_bytes += vendor_fill logger.info(f"Writing vendor {vendor}") self.write(vendor_bytes, self._cvendor_index_start)
[docs] @validated def cmodel(self) -> str: """ Get the chassis model. Returns ------- The model of the chassis. """ return ( self._content[ self._cmodel_index_start : self._cmodel_index_start + self._cmodel_length ] .strip(b"\x00") .decode("utf-8") )
[docs] def write_cmodel(self, model: str) -> None: """ Write the model of the chassis to EEPROM. Parameters ---------- model The model of the chassis. """ model_bytes = model.encode("utf-8") model_fill = b"\x00" * (self._cmodel_length - len(model_bytes)) model_bytes += model_fill logger.info(f"Writing model {model}") self.write(model_bytes, self._cmodel_index_start)
[docs] @validated def cserial(self) -> int: """ Get the chassis serial number. Returns ------- The serial number of the chassis. """ area = self._content[ self._cserial_index_start : self._cserial_index_start + self._cserial_length ] cserial = compute_int_from_bytes(area[::-1]) return cserial
[docs] def write_cserial(self, serial: int) -> None: """ Write the serial number of the chassis to EEPROM. Parameters ---------- serial The serial number of the chassis. """ # Check serial number is within bounds unsigned_upper_bound = (2**32) - 1 if serial < 0 or serial > unsigned_upper_bound: raise ValueError( f"Serial number must be between 0 and {unsigned_upper_bound}" ) serial_bytes = serial.to_bytes(4, byteorder="little") logger.info(f"Writing chassis serial {serial}") self.write(serial_bytes, self._cserial_index_start)
[docs] @validated def crevision(self) -> str: """ Get the revision of the chassis. Returns ------- The revision of the chassis. """ return ( self._content[ self._crevision_index_start : self._crevision_index_start + self._crevision_length ] .strip(b"\x00") .decode("utf-8") )
[docs] def write_crevision(self, revision: str) -> None: """ Write the chassis revision. Parameters ---------- revision The revision of the chassis. """ revision_bytes = revision.encode("utf-8") revision_fill = b"\x00" * (self._crevision_length - len(revision_bytes)) revision_bytes += revision_fill logger.info(f"Writing chassis revision {revision}") self.write(revision_bytes, self._crevision_index_start)
[docs] @validated def unit(self) -> int: """ Get the subsystem unit number. Returns ------- The unit number of the subsystem. """ area = self._content[self._unit_index_start] unit = compute_int_from_bytes([area]) return unit
[docs] @validated def version(self) -> int: """ Get the version of the EEPROM data scheme. Note ---- If undefined, the version is set to 255 and then defaults to 0. Returns ------- The version of the EEPROM data scheme. """ version = self._content[self._ccrc_pos_start - 1] return version
[docs] def write_version(self, version: int) -> None: """ Write the version of the EEPROM data scheme. Parameters ---------- version The version of the EEPROM data scheme. """ if version < 0 or version > 255: raise ValueError("Version must be between 0 and 255") if version == 255: logger.warning("Version 255 is undefined, setting to 0") version = 0 version_bytes = version.to_bytes(1, byteorder="little") logger.info(f"Writing version {version}") self.write(version_bytes, self._ccrc_pos_start - 1)
[docs] def write_unit(self, unit: int) -> None: """ Write the subsystem unit number. Parameters ---------- unit The unit number of the subsystem. """ unit_bytes = unit.to_bytes(1, byteorder="little") logger.info(f"Writing unit {unit}") self.write(unit_bytes, self._unit_index_start)
[docs] @validated def customer_area(self) -> bytes: """ Get the customer area of the CCU EEPROM. Returns ------- The customer area of the CCU EEPROM. """ return self._content[ self._customer_area_start : self._customer_area_start + self._customer_area_length ]
[docs] def write_customer_area(self, data: bytes) -> None: """ Write data to CCU EEPROM customer area. """ if len(data) > self._customer_area_length: raise ValueError("Data exceeds customer area length") self.write(data, self._customer_area_start)
def _compute_ccrc(self) -> int: return get_crc16_xmodem(self._cdata)
[docs] def write(self, data: bytes, offset: int = 0) -> None: try: super(Validatable_EEPROM, self).write(data, offset) except Exception as e: logger.error(f"Error writing to EEPROM, {e}") self._update_ccrc()
[docs] def custom_raw_data(self) -> bytes: raise NotImplementedError("CCU EEPROM does not have a raw content area")
[docs] def write_custom_raw_data(self, data: bytes) -> None: raise NotImplementedError("CCU EEPROM does not have a raw content area")