import struct
from abc import ABC
from typing import Union, Optional
from eip.client import EIPBaseClient
from datetime import datetime, timezone
from .PowerFlex750Base import PowerFlex750Base


class PowerFlex753(PowerFlex750Base):
    def __init__(self, deviceIpAddress: str, port: int = 44818):
        """
        Initialize a minimal EtherNet/IP client for a PowerFlex 753 and cache
        register/parameter constants (bit positions, known instance bases, and
        helper maps used by high-level APIs).

        Parameters:
            - deviceIpAddress (str): IPv4 address of the drive/adapter (for the ENETR card or embedded Ethernet).
            - port (int): TCP port for CIP (default 44818).

        Returns:
            - None: None

        Notes:
            - This class uses CIP Explicit Messaging to the DPI/Host Parameter Objects.
            See ENETR manual for supported classes (0x93 DPI, 0x9F Host) and instance math.
            - Instance base addresses for Host DPI parameters follow the 0x4000 + (port*0x400) scheme
            documented in the ENETR manual (Appendix C).
            - Method bodies that read/write parameters ultimately route through `_get_parameter`
            and `_set_parameter` below.
        """
        super().__init__(deviceIpAddress, port)
        self.CMD_BIT_STOP = 0  # 1 = Normal Stop
        self.CMD_BIT_START = 1  # 1 = Start
        self.CMD_BIT_JOG_1 = 2  # 1 = Jog 1
        self.CMD_BIT_CLEAR_FAULT = 3  # Rising Edge
        self.CMD_BIT_DIR_LO = 4  # Unipolar Direction bits [5:4]
        self.CMD_BIT_DIR_HI = 5  # Unipolar Direction bits [5:4]
        self.CMD_BIT_MANUAL = 6  # 1 = Manual Mode
        self.CMD_BIT_ACCEL_TIME_LO = 8
        self.CMD_BIT_ACCEL_TIME_HI = 9
        self.CMD_BIT_DECEL_TIME_LO = 10
        self.CMD_BIT_DECEL_TIME_HI = 11
        self.CMD_BIT_REF_SEL_1 = 12  # Reference Select bit 1
        self.CMD_BIT_REF_SEL_2 = 13  # Reference Select bit 2
        self.CMD_BIT_REF_SEL_3 = 14  # Reference Select bit 3
        self.CMD_BIT_EMRG_OVERRIDE = 15  # 1 = Emergency Override, 0 = Not Set or Clear
        self.CMD_BIT_COAST_STOP = 16  # 1 = Coast Stop
        self.CMD_BIT_CURRENT_LIMIT_STOP = 17  # 1 = Current Limit Stop, 0 = Not Current Limit Stop
        self.CMD_BIT_RUN = 18  # 1 = Run
        self.CMD_BIT_JOG_2 = 19  # 1 = Jog 2
        # Logic Status Word (Register Object, Instance 39, Attr 4)
        self.STS_BIT_RUN_READY = 0  # 1 = Ready to Run
        self.STS_BIT_ACTIVE = 1  # 1 = Active
        self.STS_BIT_CMD_DIR = 2  # 1 = Forward, 0 = Reverse
        self.STS_BIT_ACTUAL_DIR = 3  # 1 = Forward, 0 = Reverse
        self.STS_BIT_ACCELERATING = 4  # 1 = Accelerating
        self.STS_BIT_DECELERATING = 5  # 1 = Decelerating
        self.STS_BIT_ALARM = 6  # 1 = Alarm present
        self.STS_BIT_FAULT = 7  # 1 = Fault present
        self.STS_BIT_AT_SETPT_SPD = 8  # 1 = At setpoint speed
        self.STS_BIT_MANUAL = 9  # 1 = Manual mode active
        # Composite field: Speed Reference ID bits [14:10] (5-bit value)
        self.STS_BIT_SPD_REF_ID_0 = 10
        self.STS_BIT_SPD_REF_ID_1 = 11
        self.STS_BIT_SPD_REF_ID_2 = 12
        self.STS_BIT_SPD_REF_ID_3 = 13
        self.STS_BIT_SPD_REF_ID_4 = 14
        # Optional helpers for extracting the field (constants only; no funcs)
        self.STS_FIELD_SPD_REF_ID_MASK = 0b11111 << 10
        self.STS_FIELD_SPD_REF_ID_SHIFT = 10
        self.STS_BIT_EMRG_OVERRIDE = 15  # 1 = Emergency Override active
        self.STS_BIT_RUNNING = 16  # 1 = Running
        self.STS_BIT_JOGGING = 17  # 1 = Jogging
        self.STS_BIT_STOPPING = 18  # 1 = Stopping
        self.STS_BIT_DC_BRAKE = 19  # 1 = DC Braking
        self.STS_BIT_DB_ACTIVE = 20  # 1 = Dynamic Brake Active
        self.STS_BIT_SPEED_MODE = 21  # 1 = Speed mode
        self.STS_BIT_POSITION_MODE = 22  # 1 = Position mode
        self.STS_BIT_TORQUE_MODE = 23  # 1 = Torque mode
        self.STS_BIT_AT_ZERO_SPEED = 24  # 1 = At zero speed
        self.STS_BIT_AT_HOME = 25  # 1 = At home
        self.STS_BIT_AT_LIMIT = 26  # 1 = At limit
        self.STS_BIT_CURRENT_LIMIT = 27  # 1 = At current limit
        self.STS_BIT_BUS_FREQ_REG = 28  # 1 = Bus frequency regulation active
        self.STS_BIT_ENABLE_ON = 29  # 1 = Enable On
        self.STS_BIT_MOTOR_OVERLOAD = 30  # 1 = Motor overload
        self.STS_BIT_REGEN = 31  # 1 = Regenerating
        # Fault Codes
        self.DRIVE_POWER_UP_FC = 49  # Fault Code
        self.CLEAR_FAULT_QUEUE_FC = 51  # Fault Codes
        self.DPI_INSTANCE_OFFSET = {
            0: 0,
            1: 16384,
            2: 18432,
            3: 19456,
            4: 20480,
            5: 21504,
            6: 22528,
            7: 23552,
            8: 24576,
            9: 25600,
            10: 26624,
            11: 27648,
            12: 28672,
            13: 29696,
            14: 30720,
        }

    ##########################
    ## Public Class Methods ##
    ##########################
    ####################
    ## Public Getters ##
    ####################
    def get_motor_np_rpm(self) -> float:
        """
        Read Port 0 P28 [Motor NP RPM].

        Parameters:
            - None

        Returns:
            - float: Nameplate speed (RPM).

        Notes:
            - Motor NP RPM (P28) is used along with poles and frequency for speed calcs. :contentReference[oaicite:17]{index=17}
        """
        return self._get_parameter(parameterNumber=28, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_motor_np_volts(self) -> float:
        """
        Read Port 0 P25 [Motor NP Volts].

        Parameters:
            - None

        Returns:
            - float: Nameplate motor voltage.

        Notes:
            - Motor nameplate and rated values reside in Port 0. 'Rated Volts' (P20) and
            'Motor NP Volts' (P25) are defined in the programming manual. :contentReference[oaicite:15]{index=15}
        """
        return self._get_parameter(parameterNumber=25, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_motor_np_amps(self) -> float:
        """
        Read Port 0 P27 [Motor NP Amps].

        Parameters:
            - None

        Returns:
            - float: Nameplate motor current in amps.

        Notes:
            - See Port 0 motor NP parameters in the programming manual. :contentReference[oaicite:16]{index=16}
        """
        return round(self._get_parameter(parameterNumber=26, portNumber=0, dataType="REAL", attributeId=0x09), 2)

    def get_motor_np_hertz(self) -> float:
        """
        Read Port 0 P26 [Motor NP Hertz].

        Parameters:
            - None

        Returns:
            - float: Nameplate frequency (Hz).

        Notes:
            - Motor NP frequency is part of the nameplate set in Port 0. :contentReference[oaicite:18]{index=18}
        """
        return self._get_parameter(parameterNumber=27, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_motor_np_power_units(self) -> str:
        """
        Read Port 0 P29 [Mtr NP Pwr Units].

        Parameters:
            - None

        Returns:
            - str: 'HP' or 'kW' per drive parameter.

        Notes:
            - Power units and value are set in P29/P30 nameplate group. :contentReference[oaicite:19]{index=19}
        """
        returnValue = self._get_parameter(parameterNumber=29, portNumber=0, dataType="INT", attributeId=0x09)
        if returnValue == 0:
            return "HP"
        elif returnValue == 1:
            return "kW"
        else:
            raise ValueError(f"Power Units return value was neither 0 (HP) or 1 (kW), return value is unknown: {returnValue}")

    def get_motor_np_power(self) -> float:
        """
        Read Port 0 P30 [Motor NP Power].

        Parameters:
            - None

        Returns:
            - float: Nameplate motor power in the units specified by P29.

        Notes:
            - Pair with get_motor_np_power_units() to interpret units. :contentReference[oaicite:20]{index=20}
        """
        return self._get_parameter(parameterNumber=30, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_motor_poles(self) -> int:
        """
        Read Port 0 P31 [Motor NP Poles].

        Parameters:
            - None

        Returns:
            - int: Number of motor poles.

        Notes:
            - Used for derived speed/inertia calculations in some modes. :contentReference[oaicite:21]{index=21}
        """
        return self._get_parameter(parameterNumber=31, portNumber=0, dataType="INT", attributeId=0x09)

    def get_rated_volts(self) -> float:
        """
        Read Port 0 P20 [Rated Volts].

        Parameters:
            - None

        Returns:
            - float: Drive system rated voltage.

        Notes:
            - Rated Volts/Amps/kW appear in the 'Drive Data - Rated Values' group (Port 0). :contentReference[oaicite:22]{index=22}
        """
        return self._get_parameter(parameterNumber=20, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_rated_amps(self) -> float:
        """
        Read Port 0 P21 [Rated Amps].

        Parameters:
            - None

        Returns:
            - float: Drive rated current.

        Notes:
            - See Port 0 rated values group in programming manual. :contentReference[oaicite:23]{index=23}
        """
        return self._get_parameter(parameterNumber=21, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_rated_kw(self) -> float:
        """
        Read Port 0 P22 [Rated kW].

        Parameters:
            - None

        Returns:
            - float: Rated power (kW).

        Notes:
            - Rated kW is available in Port 0 Drive Data. :contentReference[oaicite:24]{index=24}
        """
        return self._get_parameter(parameterNumber=22, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_output_frequency(self) -> float:
        """
        Read Port 0 P1 [Output Frequency].

        Parameters:
            - None

        Returns:
            - float: Output frequency in Hz.

        Notes:
            - 'Output Frequency' = P1; see 'Start/Stop → Monitoring' table for Port 0. :contentReference[oaicite:25]{index=25}
        """
        return self._get_parameter(parameterNumber=1, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_output_current(self) -> float:
        """
        Read Port 0 P7 [Output Current].

        Parameters:
            - None

        Returns:
            - float: Output current (A).

        Notes:
            - 'Output Current' = P7 (Port 0). :contentReference[oaicite:26]{index=26}
        """
        return self._get_parameter(parameterNumber=7, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_output_voltage(self) -> float:
        """
        Read Port 0 P8 [Output Voltage].

        Parameters:
            - None

        Returns:
            - float: Output voltage.

        Notes:
            - 'Output Voltage' = P8 (Port 0). :contentReference[oaicite:27]{index=27}
        """
        return self._get_parameter(parameterNumber=8, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_output_power(self) -> float:
        """
        Read Port 0 P9 [Output Power].

        Parameters:
            - None

        Returns:
            - float: Output power.

        Notes:
            - 'Output Power' = P9 (Port 0).
        """
        return self._get_parameter(parameterNumber=9, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_speed_ref_a_sp(self) -> float:
        """
        Read Port 0 P546 [Ref A Setpt] (speed reference A setpoint).

        Parameters:
            - None

        Returns:
            - float: Reference A setpoint (drive units).

        Notes:
            - For reference/feedback scaling via ENETR Reference/Feedback words,
            see ENETR 'Using the I/O' chapter (Reference/Feedback are 32‑bit REALs).
        """
        return self._get_parameter(parameterNumber=546, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_speed_ref_a_sel(self) -> int:
        """
        Read Port 0 P545 [Speed Ref A Sel] (SSPPP code for Ref A source).
        Returns:
            int: SSPPP code; expected 546 for Port0:P546 [Ref A Setpt].
        """
        return self._get_parameter(parameterNumber=545, portNumber=0, dataType="DINT", attributeId=0x09)

    def get_speed_ref_b_sp(self) -> float:
        """
        Read Port 0 P551 [Ref B Setpt] (speed reference B setpoint).

        Parameters:
            - None

        Returns:
            - float: Reference B setpoint (drive units).

        Notes:
            - Reference/Feedback usage and locations in the I/O image are described
            in ENETR, Ch. 5.
        """
        return self._get_parameter(parameterNumber=551, portNumber=0, dataType="REAL", attributeId=0x09)

    def get_speed_ref_b_sel(self) -> int:
        """
        Read Port 0 P545 [Speed Ref B Sel] (SSPPP code for Ref B source).
        Returns:
            int: SSPPP code; expected 550 for Port0:P550 [Ref B Sel].
        """
        return self._get_parameter(parameterNumber=550, portNumber=0, dataType="DINT", attributeId=0x09)

    def get_msg_control_timeout(self) -> float:
        """
        Read the 'Control Timeout' for the Assembly Object (class 0x04) via Attribute 100.

        Parameters:
            - None

        Returns:
            - int: Timeout value (seconds).

        Notes:
            - ENETR Appendix C lists Assembly Object class attributes (Attr 100 = Set Control Timeout),
            which gate how long control is maintained without I/O. :contentReference[oaicite:94]{index=94}
        """
        return self._get_parameter(parameterNumber=0, portNumber=0, dataType="UINT", attributeId=100, classId=0x04, instanceBase=0)

    def get_register_control_timeout(self) -> float:
        """
        Read the 'Control Timeout' for the Register Object (class 0x07) via Attribute 100.

        Parameters:
            - None

        Returns:
            - int: Timeout value (seconds).

        Notes:
            - ENETR Appendix C includes Register Object attributes for control timeouts. :contentReference[oaicite:95]{index=95}
        """
        return self._get_parameter(parameterNumber=0, portNumber=0, dataType="UINT", attributeId=100, classId=0x07, instanceBase=0)

    def get_logic_status(self) -> int:
        """
        Read the 32‑bit Logic Status word (Register Object).

        Parameters:
            - None

        Returns:
            - int: Logic Status word (bit‑mapped).

        Notes:
            - Register Object instances provide Logic Status/Feedback (RO) and
            Logic Command/Reference (RW). See ENETR Appendix C (instances). :contentReference[oaicite:31]{index=31}
            - Logic Command/Status bit definitions for PF 750‑Series are summarized in ENETR,
            Appendix D; examples in Ch. 5 also reference these words. :contentReference[oaicite:32]{index=32}
        """
        return self._get_parameter(parameterNumber=0, portNumber=0, dataType="UDINT", attributeId=4, classId=0x07, instanceBase=39)

    def get_feedback_speed(self) -> float:
        """
        Read the 32‑bit REAL speed Feedback from the Register Object.

        Parameters:
            - None

        Returns:
            - float: Feedback speed (REAL).

        Notes:
            - ENETR shows Reference and Feedback are 32‑bit REAL values in the I/O image and via the Register Object instances. :contentReference[oaicite:33]{index=33}
        """
        return self._get_parameter(parameterNumber=0, portNumber=0, dataType="REAL", attributeId=4, classId=0x07, instanceBase=41)

    def get_run_ready(self) -> bool:  # Bit 0
        """
        Test Logic Status bit 0 'Run Ready'.

        Parameters:
            - None

        Returns:
            - bool: True if the drive is ready to run.

        Notes:
            - Logic Status bit definitions are in ENETR Appendix D. :contentReference[oaicite:47]{index=47}
        """
        return self._get_logic_status_bit(0)

    def get_active(self) -> bool:  # Bit 1
        """
        Test Logic Status bit 1 'Active'.

        Parameters:
            - None

        Returns:
            - bool: True when active.

        Notes:
            - see Appendix D
        """
        return self._get_logic_status_bit(1)

    def get_command_direction(self) -> bool:  # Bit 2; True=Forward, False=Reverse
        """
        Test Logic Status bit 2 'Cmd Dir'.

        Returns:
            bool: True for forward, False for reverse.

        Notes:
            - Logic Status word bit 2 = 'Command Dir': 1 = Forward, 0 = Reverse.
            See *PowerFlex 750-Series*: Logic Status Word (Appendix D).
        """
        return self._get_logic_status_bit(2)

    def get_actual_direction(self) -> bool:  # Bit 3; True=Forward, False=Reverse
        """
        Test Logic Status bit 3 'Actual Dir'.

        Returns:
            - bool: True for forward, False for reverse.
        Notes:
            - Appendix D.
        """

        return self._get_logic_status_bit(3)

    def get_accelerating(self) -> bool:  # Bit 4
        """
        Test bit 4 'Accelerating'.
        Returns:
            - bool: True for accelerating
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(4)

    def get_decelerating(self) -> bool:  # Bit 5
        """
        Test bit 5 'Decelerating'.

        Returns:
            - bool: True for decelerating
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(5)

    def get_alarm(self) -> bool:  # Bit 6
        """
        Test bit 6 'Alarm'.

        Returns:
            - bool: True for Alarm Active
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(6)

    def get_fault_present(self) -> bool:  # Bit 7
        """
        Test bit 7 'Fault Present'.

        Returns:
            - bool: True for Fault Present
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(7)

    def get_at_setpoint_speed(self) -> bool:  # Bit 8
        """
        Test bit 8 'At Setpt'.
        Returns:
            - bool: True for the motor being at speed
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(8)

    def get_manual_mode_active(self) -> bool:  # Bit 9
        """
        Test bit 9 'Manual'.

        Returns:
            - bool: True for the motor being in manual mode
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(9)

    def get_emergency_override_active(self) -> bool:  # Bit 15
        """
        Test bit 15 'Emergency Override'.

        Returns:
            - bool: True for the Emergency Override being active
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(15)

    def get_running(self) -> bool:  # Bit 16
        """
        Test bit 16 'Running'.

        Returns:
            - bool: True for the motor to be running
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(16)

    def get_jogging(self) -> bool:  # Bit 17
        """
        Test bit 17 'Jogging'.

        Returns:
            - bool: True for the motor to be jogging
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(17)

    def get_stopping(self) -> bool:  # Bit 18
        """
        Test bit 18 'Stopping'.

        Returns:
            - bool: True for the motor to be stopping
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(18)

    def get_dc_brake(self) -> bool:  # Bit 19
        """
        Test bit 19 'DC Brake'.

        Returns:
            - bool: True for the DC brake being applied
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(19)

    def get_dynamic_brake_active(self) -> bool:  # Bit 20
        """
        Test bit 20 'DB Active'.

        Returns:
            - bool: True for the dynamic brake being applied
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(20)

    def get_speed_mode(self) -> bool:  # Bit 21
        """
        Test bit 21 'Speed Mode'.

        Returns:
            - bool: True for VFD being in speed mode
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(21)

    def get_position_mode(self) -> bool:  # Bit 22
        """
        Test bit 22 'Position Mode'.

        Returns:
            - bool: True for VFD being in position mode
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(22)

    def get_torque_mode(self) -> bool:  # Bit 23
        """
        Test bit 23 'Torque Mode'.

        Returns:
            - bool: True for VFD being in torque mode
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(23)

    def get_at_zero_speed(self) -> bool:  # Bit 24
        """
        Test bit 24 'At Zero Speed'.

        Returns:
            - bool: True for the motor being at zero speed
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(24)

    def get_at_home(self) -> bool:  # Bit 25
        """
        Test bit 25 'At Home'.

        Returns:
            - bool: True for VFD being at the home position
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(25)

    def get_at_limit(self) -> bool:  # Bit 26
        """
        Test bit 26 'At Limit'.

        Returns:
            - bool: True for VFD being at limit
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(26)

    def get_current_limit(self) -> bool:  # Bit 27
        """
        Test bit 27 'Current Limit'.

        Returns:
            - bool: True for VFD hitting the current limit
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(27)

    def get_bus_freq_reg(self) -> bool:  # Bit 28
        """
        Test bit 28 'Bus/Freq Reg'.

        Returns:
            - bool: True for VFD having a regular bus frequency
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(28)

    def get_enable_on(self) -> bool:  # Bit 29
        """
        Test bit 29 'Enable On'.

        Returns:
            - bool: True for VFD being enabled
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(29)

    def get_motor_overload(self) -> bool:  # Bit
        """
        Test bit 30 'Motor Overload'.

        Returns:
            - bool: True for the motor overloading
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(30)

    def get_regen(self) -> bool:  # Bit 31
        """
        Test bit 31 'Regen'.

        Returns:
            - bool: True for VFD being in regen
        Notes:
            - Appendix D.
        """
        return self._get_logic_status_bit(31)

    def get_spd_ref_id(self) -> int:
        """
        Extract the active speed reference ID (bits 10..14) from the Logic Status word.

        Parameters:
            - None

        Returns:
            - int: 5‑bit code indicating the active reference source.

        Notes:
            - Logic Status includes a reference selection field; mapping to a label is
        implemented in get_spd_ref_id_label(). :contentReference[oaicite:74]{index=74}
        """
        logicWord = int(self.get_logic_status())
        return (logicWord >> 10) & 0x1F

    def get_spd_ref_id_label(self) -> str:
        """
        Decode get_spd_ref_id() into a human‑readable label (Auto/Manual refs).

        Parameters:
            - None

        Returns:
            - str: Label for the active speed reference.

        Notes:
            - Label map covers Auto Ref A/B, auto preset speeds, and manual port selections. :contentReference[oaicite:75]{index=75}
        """
        code = self.get_spd_ref_id()
        labels = {
            0b00001: "Auto Ref A",
            0b00010: "Auto Ref B",
            0b00011: "Auto Preset Speed 3",
            0b00100: "Auto Preset Speed 4",
            0b00101: "Auto Preset Speed 5",
            0b00110: "Auto Preset Speed 6",
            0b00111: "Auto Preset Speed 7",
            0b10000: "Manual Port 0",
            0b10001: "Manual Port 1",
            0b10010: "Manual Port 2",
            0b10011: "Manual Port 3",
            0b10100: "Manual Port 4",
            0b10101: "Manual Port 5",
            0b10110: "Manual Port 6",
            0b11101: "Manual Port 13 (embedded ENET)",
            0b11110: "Manual Port 14 (DriveLogix)",
            0b11111: "Alternate Manual Ref Sel",
        }
        return labels.get(code, "Reserved")

    def get_msg_fault_action(self, portNumber: int = 4) -> int:
        """
        Read Host Parameter P36 [Msg Flt Action] for the adapter in portNumber.

        Parameters:
            - portNumber (int): Host port of the adapter (default 4).

        Returns:
            - int: Action code (0=Fault, 1=Stop, 2=Zero Data, 3=Hold Last).

        Notes:
            - Host parameters for the option module are accessed via Host DPI class 0x9F,
            instance = 0x4000 + (port*0x400) + 36, attribute 0x09 (Value). :contentReference[oaicite:81]{index=81}
            - Fault‑Action parameters (Idle/Comm/Msg → P34/P35/P36) and their meanings
            are listed in ENETR Ch. 3/5. :contentReference[oaicite:82]{index=82}
        """
        if not isinstance(portNumber, int) or portNumber < 1 or portNumber > 14:
            raise ValueError("hostPortNumber must be an int in 1..14 (ENETR typically 4, 5, or 6)")
        instanceId = 0x4000 + (portNumber * 0x400) + 36
        rawBytesList = self.eeipClient.get_attribute_single(
            class_id=0x9F,
            instance_id=instanceId,
            attribute_id=0x09,
        )
        valueBytes = bytes(rawBytesList)
        if len(valueBytes) < 4:
            raise ValueError(f"Msg Flt Action returned {len(valueBytes)} byte(s); expected 4 for DINT")
        actionValue = struct.unpack("<i", valueBytes[:4])[0]
        return int(actionValue)

    def get_idle_fault_action(self, hostPortNumber: int = 4) -> int:
        """
        Read Host Parameter P34 [Idle Flt Action] for the adapter in hostPortNumber.

        Parameters:
            - hostPortNumber (int): Host port of the adapter (default 4).

        Returns:
            - int: Action code (0=Fault, 1=Stop, 2=Zero Data, 3=Hold Last).

        Notes:
            - Host DPI class 0x9F, instance = 0x4000 + (port*0x400) + 34, attribute 0x09. :contentReference[oaicite:83]{index=83}
            - See ENETR for action code descriptions. :contentReference[oaicite:84]{index=84}
        """
        if not isinstance(hostPortNumber, int) or not (1 <= hostPortNumber <= 14):
            raise ValueError("hostPortNumber must be 1..14 (ENETR typically 4, 5, or 6)")
        instanceId = 0x4000 + (hostPortNumber * 0x400) + 34
        rawBytesList = self.eeipClient.get_attribute_single(class_id=0x9F, instance_id=instanceId, attribute_id=0x09)
        valueBytes = bytes(rawBytesList)
        if len(valueBytes) < 4:
            raise ValueError("Idle Flt Action returned fewer than 4 bytes")
        return int(struct.unpack("<i", valueBytes[:4])[0])

    def get_comm_fault_action(self, hostPortNumber: int = 4) -> int:
        """
        Read Host Parameter P35 [Comm Flt Action] for the adapter in hostPortNumber.

        Parameters:
            - hostPortNumber (int): Host port of the adapter (default 4).

        Returns:
            - int: Action code (0=Fault, 1=Stop, 2=Zero Data, 3=Hold Last).

        Notes:
            - Host DPI class 0x9F, instance = 0x4000 + (port*0x400) + 35, attribute 0x09. :contentReference[oaicite:85]{index=85}
            - See ENETR for action code descriptions. :contentReference[oaicite:86]{index=86}
        """
        if not isinstance(hostPortNumber, int) or not (1 <= hostPortNumber <= 14):
            raise ValueError("hostPortNumber must be 1..14 (ENETR typically 4, 5, or 6)")
        instanceId = 0x4000 + (hostPortNumber * 0x400) + 35
        rawBytesList = self.eeipClient.get_attribute_single(class_id=0x9F, instance_id=instanceId, attribute_id=0x09)
        valueBytes = bytes(rawBytesList)
        if len(valueBytes) < 4:
            raise ValueError("Comm Flt Action returned fewer than 4 bytes")
        return int(struct.unpack("<i", valueBytes[:4])[0])

    def get_fault_queue_count(self) -> int:
        """
        Read the number of recorded faults/events from the DPI Fault Object.

        Parameters:
            - None

        Returns:
            - int: Count of entries in the fault/event queue.

        Notes:
            - Uses the DPI Fault Object (class 0x97), Attribute 6 (Number of Recorded Faults). :contentReference[oaicite:76]{index=76}
        """
        rawBytesList = self.eeipClient.get_attribute_single(
            class_id=0x97,  # DPI Fault Object
            instance_id=0,  # instance 0 targets class attributes
            attribute_id=6,  # Get Number of Recorded Faults (UINT)
        )
        valueBytes = bytes(rawBytesList)
        if len(valueBytes) < 2:
            raise ValueError("Fault queue count returned fewer than 2 bytes")
        return int(struct.unpack("<H", valueBytes[:2])[0])

    def get_fault(self, instanceIndex: int = 1) -> dict:
        """
        Read a single fault/event record from the DPI Fault Object and format it.

        Parameters:
            - queueIndex (int) : 1‑based index into the fault/event queue (1 = most recent).

        Returns:
            - dict: Parsed fields including code, source, dpiPort, faultText, timestamp, flags.

        Notes:
            - Reads class 0x97 Attribute 0 (Full fault record STRUCT) and decodes code,
            source/port, ASCII text, timestamp, flags, and timer fields. :contentReference[oaicite:77]{index=77}
            - Timestamps can be 'real‑time' or 'elapsed'—see `_format_ts_real_time` and
            `_format_ts_elapsed` helpers. :contentReference[oaicite:78]{index=78}
        """
        count = self.get_fault_queue_count()  # Saftey catch for bounding our access
        if instanceIndex > count:
            raise ValueError(
                f"instanceIndex can not exceed fault queue count: {instanceIndex} (Instance Index) > {count} (Fault Queue Count)"
            )
        if instanceIndex < 1:
            raise ValueError("instanceIndex must be >= 1 (1 = most recent)")

        rawBytesList = self.eeipClient.get_attribute_single(
            class_id=0x97, instance_id=instanceIndex, attribute_id=0  # DPI Fault Object  # Get Full/All Information
        )
        valueBytes = bytes(rawBytesList)
        if len(valueBytes) < 32:  # 2 + 1 + 1 + 16 + 8 + 2 + 2 (fixed portion)
            raise ValueError(f"Fault attribute 0 returned {len(valueBytes)} byte(s); expected >= 32")

        code = struct.unpack_from("<H", valueBytes, 0)[0]  # 0..1
        source = valueBytes[2]  # 2
        dpiPort = valueBytes[3]  # 3
        faultText = valueBytes[4:20].decode("ascii", errors="ignore").rstrip("\x00")  # 4..19
        timestamp = struct.unpack_from("<Q", valueBytes, 20)[0]  # 20..27
        flagsMask = struct.unpack_from("<H", valueBytes, 28)[0]  # 28..29
        timerValue = struct.unpack_from("<H", valueBytes, 30)[0]  # 30..31

        isValid = bool(flagsMask & 0x0001)  # flags[0]
        isRealTime = bool(flagsMask & 0x0002)  # flags[1]

        if not isValid:
            formattedTimestamp = "N/A (timestamp invalid per flags)"
        else:
            formattedTimestamp = self._format_ts_real_time(timestamp) if isRealTime else self._format_ts_elapsed(timestamp)

        return {
            "code": int(code),
            "source": int(source),
            "dpiPort": int(dpiPort),
            "faultText": faultText,
            "timestampRaw": int(timestamp),
            "timestamp": formattedTimestamp,  # human-readable per rules above
            "timerValue": int(timerValue),
            "isValid": isValid,
            "isRealTime": isRealTime,
            "rawFlags": f"0b{flagsMask:016b}",  # 16-bit binary mask for easy visual parsing
        }

    def get_logic_command_masked_direction(self):
        """
        Read the 'Direction' attribute of the Masked Logic Command register to
        confirm it is Consumer=1 (i.e., write‑only from controller).

        Parameters:
            - None

        Returns:
            - bool: Direction attribute as BOOL (True when Consumer).

        Notes:
            - Accesses Register Object, instance 38, Attribute 2 (Direction).
        """
        return self._get_parameter(parameterNumber=0, portNumber=0, dataType="BOOL", attributeId=2, classId=0x07, instanceBase=38)

    def get_module_ptc_cfg(self, portNumber: int = 5) -> int:
        """
        Read Port n P40 [Module PTC Cfg] (11‑Series I/O module configuration).

        Parameters:
        portNumber (int) : I/O module port (default 5).

        Returns:
        int: Mode code (module-specific options 0..5).

        Notes:
        - 11‑Series I/O parameters include module configuration words in the
        low parameter range (e.g., P40). :contentReference[oaicite:136]{index=136}
        """
        return self._get_parameter(parameterNumber=40, portNumber=portNumber, dataType="DINT", attributeId=0x09, classId=0x9F)

    def get_module_digital_inputs_word(self, portNumber: int = 5) -> int:
        """
        Read Port n P1 [Dig In Sts] — 22-Series I/O digital input status word.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - int: Bit mask of digital input states (e.g., DI0..DI5).

        Notes:
            - For 22-Series I/O, P1 provides the current DI bitfield for the module on Port n.
            - Bit positions correspond to the channel numbering in the module’s datasheet.
        """
        return int(self._get_parameter(parameterNumber=1, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F))

    def get_module_digital_input(self, inputIndex: int, portNumber: int = 5) -> bool:
        """
        Read a single digital input state (DI[inputIndex]) from the 22-Series I/O module.

        Parameters:
            - inputIndex (int) : Zero-based input index (e.g., 0 for DI0).
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - bool: True if the selected input is ON, False otherwise.

        Notes:
            - Reads P1 [Dig In Sts] and masks the bit at inputIndex.
        """
        if not (0 <= inputIndex <= 5):
            raise ValueError("inputIndex must be 0..5 for DI0..DI5")
        word = self.get_module_digital_inputs_word(portNumber)
        return bool((word >> inputIndex) & 0x1)

    def get_module_analog_input_0_value(self, portNumber: int = 5) -> float:
        """
        Read Port n P50 [Anlg In0 Value] — 22-Series AI0 scaled value.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - float: Scaled analog input 0 value (engineering units).

        Notes:
            - AI0 scaling is defined by P51 (High) and P52 (Low).
        """
        return float(self._get_parameter(parameterNumber=50, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F))

    def get_module_analog_input_0_upper_scaling(self, portNumber: int = 5) -> float:
        """
        Read Port n P51 [Anlg In0 Hi] — 22-Series AI0 scaling high.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - float: Upper engineering limit applied to AI0 scaling.

        Notes:
            - Pair with P52 (Low) to interpret P50 [Value].
        """
        return self._get_parameter(parameterNumber=51, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F)

    def get_module_analog_input_0_lower_scaling(self, portNumber: int = 5) -> float:
        """
        Read Port n P52 [Anlg In0 Lo] — 22-Series AI0 scaling low.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - float: Lower engineering limit applied to AI0 scaling.

        Notes:
            - Together with P51, defines the AI0 engineering range.
        """
        return self._get_parameter(parameterNumber=52, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F)

    def get_module_analog_input_0_mode(self, portNumber: int = 5) -> str:
        """
        Read Port n P45 [Anlg In Mode] and decode AI0 mode for a 22-Series module.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - str: "VOLTAGE" if AI0 is in voltage mode, "CURRENT" if in current mode.

        Notes:
            - P45 is a bit-coded mode word; the AI0 selection bit is defined for the 22-Series module.
            - Mode determines valid scaling and wiring (e.g., ±10 V vs 4–20 mA).
        """
        word = self._get_parameter(parameterNumber=45, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F)
        return "CURRENT" if (word & 0x0001) else "VOLTAGE"

    def get_module_analog_input_1_value(self, portNumber: int = 5) -> float:
        """
        Read Port n P60 [Anlg In1 Value] — 22-Series AI1 scaled value.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - float: Scaled analog input 1 value (engineering units).

        Notes:
            - AI1 scaling is defined by P61 (High) and P62 (Low).
        """
        return float(self._get_parameter(parameterNumber=60, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F))

    def get_module_analog_input_1_upper_scaling(self, portNumber: int = 5) -> float:
        """
        Read Port n P61 [Anlg In1 Hi] — 22-Series AI1 scaling high.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - float: Upper engineering limit applied to AI1 scaling.

        Notes:
            - Pair with P62 (Low) to interpret P60 [Value].
        """
        return self._get_parameter(parameterNumber=61, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F)

    def get_module_analog_input_1_lower_scaling(self, portNumber: int = 5) -> float:
        """
        Read Port n P62 [Anlg In1 Lo] — 22-Series AI1 scaling low.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - float: Lower engineering limit applied to AI1 scaling.

        Notes:
            - Together with P61, defines the AI1 engineering range.
        """
        return self._get_parameter(parameterNumber=62, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F)

    def get_module_analog_input_1_mode(self, portNumber: int = 5) -> str:
        """
        Read Port n P45 [Anlg In Mode] and decode AI1 mode for a 22-Series module.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - str: "VOLTAGE" if AI1 is in voltage mode, "CURRENT" if in current mode.

        Notes:
            - P45 bit assignment includes AI1 for the 22-Series module.
        """
        word = self._get_parameter(parameterNumber=45, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F)
        return "CURRENT" if (word & 0x0002) else "VOLTAGE"

    def get_module_analog_output_0_value(self, portNumber: int = 5) -> float:
        """
        Read Port n P82 [Anlg Out0 Val] — AO0 direct value path (22-Series).

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - float: AO0 value when P75 selects the Value path.

        Notes:
            - The 22-Series AO can be driven via Value, Setpoint, or selected Data.
        """
        return float(self._get_parameter(parameterNumber=82, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F))

    def get_module_analog_output_0_mode(self, portNumber: int = 5) -> str:
        """
        Read Port n P70 [Anlg Out Mode] and decode AO0 mode for a 22-Series module.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - str: "VOLTAGE" if AO0 is in voltage mode, "CURRENT" if in current mode.

        Notes:
            - For 22-Series AO, bit assignments in P70 identify channel modes (AO0 uses bit0).
        """
        word = self._get_parameter(parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F)
        return "CURRENT" if (int(word) & 0x0001) else "VOLTAGE"

    def get_module_analog_output_0_upper_limit(self, portNumber: int = 5) -> float:
        """
        Read Port n P80 [Anlg Out0 Hi] — 22-Series AO0 upper clamp.

        Parameters:
            - portNumber (int): I/O module port (default 5).

        Returns:
            - float: AO0 upper limit (engineering units).

        Notes:
            - Limits should be compatible with the selected AO0 mode (voltage/current).
        """
        return float(self._get_parameter(parameterNumber=80, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F))

    def get_module_analog_output_0_lower_limit(self, portNumber: int = 5) -> float:
        """
        Read Port n P81 [Anlg Out0 Lo] — 22-Series AO0 lower clamp.

        Parameters:
            - portNumber (int): I/O module port (default 5).

        Returns:
            - float: AO0 lower limit (engineering units).

        Notes:
            - Used with P80 to bound the AO0 output.
        """
        return float(self._get_parameter(parameterNumber=81, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F))

    def get_module_analog_output_0_selection(self, portNumber: int = 5) -> int:
        """
        Read Port n P75 [Anlg Out0 Sel] — AO0 source pointer (SSPPPP) for 22-Series.

        Parameters:
            - portNumber (int): I/O module port (default 5).

        Returns:
            - int: SSPPPP source code (10000 * port + parameter).

        Notes:
            - To use the local setpoint, point P75 to Port n:P76.
        """
        return self._get_parameter(parameterNumber=75, portNumber=portNumber, dataType="DINT", attributeId=0x09, classId=0x9F)

    def get_module_analog_output_0_setpoint(self, portNumber: int = 5) -> float:
        """
        Read Port n P76 [Anlg Out0 Stpt] — AO0 local setpoint (22-Series).

        Parameters:
            - portNumber (int): I/O module port (default 5).

        Returns:
            - float: AO0 setpoint in engineering units.

        Notes:
            - When P75 selects P76, AO0 follows this setpoint.
        """
        return self._get_parameter(parameterNumber=76, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F)

    def get_module_analog_output_0_data(self, portNumber: int = 5) -> float:
        """
        Read Port n P77 [Anlg Out0 Data] — AO0 selected-source readback (22-Series).

        Parameters:
            - portNumber (int): I/O module port (default 5).

        Returns:
            - float: AO0 data readback.

        Notes:
            - Mirrors the currently selected source for AO0.
        """
        return float(self._get_parameter(parameterNumber=77, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F))

    def get_module_analog_output_1_value(self, portNumber: int = 5) -> float:
        """
        Read Port n P92 [Anlg Out1 Val] — AO1 direct value path (22-Series).

        Parameters:
            - portNumber (int): I/O module port (default 5).

        Returns:
            - float: AO1 value when P85 selects the Value path.
        """
        return float(self._get_parameter(parameterNumber=92, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F))

    def get_module_analog_output_1_mode(self, portNumber: int = 5) -> str:
        """
        Read Port n P70 [Anlg Out Mode] and decode AO1 mode for a 22-Series module.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - str: "VOLTAGE" if AO1 is in voltage mode, "CURRENT" if in current mode.

        Notes:
            - For 22-Series AO, AO1 uses bit1 in P70.
        """
        word = self._get_parameter(parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F)
        return "CURRENT" if (int(word) & 0x0002) else "VOLTAGE"

    def get_module_analog_output_1_upper_limit(self, portNumber: int = 5) -> float:
        """
        Read Port n P90 [Anlg Out1 Hi] — 22-Series AO1 upper clamp.

        Parameters:
            - portNumber (int): I/O module port (default 5).

        Returns:
            - float: AO1 upper limit (engineering units).
        """
        return float(self._get_parameter(parameterNumber=90, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F))

    def get_module_analog_output_1_lower_limit(self, portNumber: int = 5) -> float:
        """
        Read Port n P91 [Anlg Out1 Lo] — 22-Series AO1 lower clamp.

        Parameters:
            - portNumber (int): I/O module port (default 5).

        Returns:
            - float: AO1 lower limit (engineering units).
        """
        return float(self._get_parameter(parameterNumber=91, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F))

    def get_module_analog_output_1_selection(self, portNumber: int = 5) -> int:
        """
        Read Port n P85 [Anlg Out1 Sel] — AO1 source pointer (SSPPPP) for 22-Series.

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - int: SSPPPP source code (10000 * port + parameter).

        Notes:
            - To use the local setpoint, point P85 to Port n:P86.
        """
        return self._get_parameter(parameterNumber=85, portNumber=portNumber, dataType="DINT", attributeId=0x09, classId=0x9F)

    def get_module_analog_output_1_setpoint(self, portNumber: int = 5) -> float:
        """
        Read Port n P86 [Anlg Out1 Stpt] — AO1 local setpoint (22-Series).

        Parameters:
            - portNumber (int): I/O module port (default 5).

        Returns:
            - float: AO1 setpoint in engineering units.
        """
        return self._get_parameter(parameterNumber=86, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F)

    def get_module_analog_output_1_data(self, portNumber: int = 5) -> float:
        """
        Read Port n P87 [Anlg Out1 Data] — AO1 selected-source readback (22-Series).

        Parameters:
            - portNumber (int) : I/O module port (default 5).

        Returns:
            - float: AO1 data readback.
        """
        return float(self._get_parameter(parameterNumber=87, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F))

    def get_logic_mask(self) -> int:
        """
        Read Port 0 P324 [Logic Mask] (bitmask enabling command sources by port).
        Returns:
            int: Bitmask; bit N enables Port N as a logic source.
        """
        return self._get_parameter(parameterNumber=324, portNumber=0, dataType="INT", attributeId=0x09)

    def get_auto_mask(self) -> int:
        """
        Read Port 0 P325 [Auto Mask] (which ports can command in Auto).
        Returns:
            int: Bitmask; bit N enables Port N in Auto mode.
        """
        return self._get_parameter(parameterNumber=325, portNumber=0, dataType="INT", attributeId=0x09)

    def get_logic_command(self) -> int:
        """
        Read the 32-bit Logic Command word (Register Object, Instance 40).

        Parameters:
        None

        Returns:
        int: Logic Command word (bit-mapped UDINT).

        Notes:
        - Register Object (class 0x07): Instance 40 = Logic Command (R/W), Attr 4 = Data (UDINT). :contentReference[oaicite:2]{index=2}
        """
        return self._get_parameter(
            parameterNumber=0,
            portNumber=0,
            dataType="UDINT",
            attributeId=4,
            classId=0x07,
            instanceBase=40,
        )

    def get_logic_status(self) -> int:
        """
        Read the 32-bit Logic Status word (Register Object, Instance 39).

        Parameters:
        None

        Returns:
        int: Logic Status word (bit-mapped UDINT).

        Notes:
        - Register Object (class 0x07): Instance 39 = Logic Status (R/O), Attr 4 = Data (UDINT).
        """
        return self._get_parameter(
            parameterNumber=0,
            portNumber=0,
            dataType="UDINT",
            attributeId=4,
            classId=0x07,
            instanceBase=39,
        )

    def get_speed_units(self) -> str:
        """
        Read Port 0 P330 [Speed Units] and decode the motor speed units.

        Parameters:
            - None

        Returns:
            str: Speed units (RPM, Hz).
        """
        speedUnits = self._get_parameter(parameterNumber=300, portNumber=0, dataType="DINT", attributeId=0x09, classId=0x9F)
        return "RPM" if speedUnits == 1 else "Hz"

    ####################
    ## Public Setters ##
    ####################

    def set_motor_np_rpm(self, newRpm: float, verify: bool = False) -> bool:
        """
        Set Port 0 P28 [Motor NP RPM] to a new nameplate speed.

        Parameters:
            - newRpm (float) : Desired motor nameplate speed in RPM (1.0..40000.0).
            - verify (bool) : If True, reads back P28 and confirms it equals newRpm.

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes CIP REAL to Port 0, Parameter 28 via DPI Parameter Object (classId=0x93, attributeId=9).
            - Basic range validation enforces 1.0..40000.0 per typical documented limits.
        """
        # Basic validation against documented limits (RW Real, 1.0..40000.0)
        if not isinstance(newRpm, (int, float)):
            raise ValueError("newRpm must be a number")
        if not (1.0 <= float(newRpm) <= 40000.0):
            raise ValueError("newRpm out of allowed range 1.0..40000.0 RPM")
        # Encode the float as CIP REAL (IEEE-754, little-endian)
        success = self._set_parameter(data=float(newRpm), parameterNumber=28, portNumber=0, dataType="REAL", attributeId=9, classId=0x93)
        if verify:
            return (self.get_motor_np_rpm() == newRpm) and success
        return success

    def set_motor_np_volts(self, newVolts: float, verify: bool = False) -> bool:
        """
        Set Port 0 P25 [Motor NP Volts] to a new nameplate voltage.

        Parameters:
            - newVolts (float) : Desired motor nameplate voltage (> 0).
            - verify (bool) : If True, reads back P25 and confirms it equals newVolts.

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes CIP REAL to Port 0, Parameter 25 (classId=0x93, attributeId=9).
            - Ensures newVolts is positive before writing.
        """
        if not isinstance(newVolts, (int, float)):
            raise ValueError("newVolts must be a number")
        if float(newVolts) <= 0.0:
            raise ValueError("newVolts must be > 0")
        success = self._set_parameter(data=float(newVolts), parameterNumber=25, portNumber=0, dataType="REAL", attributeId=9, classId=0x93)
        if verify:
            return (float(self.get_motor_np_volts()) == float(newVolts)) and success
        return success

    def set_motor_np_amps(self, newAmps: float, verify: bool = False) -> bool:
        """
        Set Port 0 P26 [Motor NP Amps] to a new nameplate current.

        Parameters:
            - newAmps (float) : Desired motor nameplate current in amps (> 0).
            - verify (bool) : If True, reads back P26 and confirms it equals newAmps (rounded to 2 decimals).

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes CIP REAL to Port 0, Parameter 26 (classId=0x93, attributeId=9).
            - Uses a 2-decimal comparison during verify to match typical display precision.
        """
        if not isinstance(newAmps, (int, float)):
            raise ValueError("newAmps must be a number")
        if float(newAmps) <= 0.0:
            raise ValueError("newAmps must be > 0")
        success = self._set_parameter(data=float(newAmps), parameterNumber=26, portNumber=0, dataType="REAL", attributeId=9, classId=0x93)
        if verify:
            return (round(float(self.get_motor_np_amps()), 2) == float(newAmps)) and success
        return success

    def set_motor_np_hertz(self, newHertz: float, verify: bool = False) -> bool:
        """
        Set Port 0 P27 [Motor NP Hertz] to a new nameplate frequency.

        Parameters:
            - newHertz (float) : Desired nameplate frequency in Hz (> 0).
            - verify (bool) : If True, reads back P27 and confirms it equals newHertz.

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes CIP REAL to Port 0, Parameter 27 (classId=0x93, attributeId=9).
        """
        if not isinstance(newHertz, (int, float)):
            raise ValueError("newHertz must be a number")
        if float(newHertz) <= 0.0:
            raise ValueError("newHertz must be > 0")
        success = self._set_parameter(data=float(newHertz), parameterNumber=27, portNumber=0, dataType="REAL", attributeId=9, classId=0x93)
        if verify:
            return (float(self.get_motor_np_hertz()) == float(newHertz)) and success
        return success

    def set_motor_np_power_units(self, unitsCode: int, verify: bool = False) -> bool:
        """
        Set Port 0 P29 [Mtr NP Pwr Units] to HP or kW.

        Parameters:
            - unitsCode (int) : 0 for HP, 1 for kW.
            - verify (bool) : If True, reads back P29 and confirms the decoded units match.

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes DINT to Port 0, Parameter 29 (classId=0x93, attributeId=9).
            - The get_motor_np_power_units() function decodes 0→"HP", 1→"kW".
        """
        if not isinstance(unitsCode, int):
            raise ValueError("unitsCode must be an integer")
        if unitsCode not in (0, 1):
            raise ValueError("unitsCode must be 0 (HP) or 1 (kW)")
        success = self._set_parameter(data=int(unitsCode), parameterNumber=29, portNumber=0, dataType="DINT", attributeId=9, classId=0x93)
        if verify:
            return (self.get_motor_np_power_units() == "HP" if unitsCode == 0 else "kW") and success
        return success

    def set_motor_np_power(self, newPower: float, verify: bool = False) -> bool:
        """
        Set Port 0 P30 [Motor NP Power] to a new nameplate power.

        Parameters:
            - newPower (float) : Desired nameplate power (> 0), in units defined by P29 (HP or kW).
            - verify (bool) : If True, reads back P30 and confirms it equals newPower.

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes CIP REAL to Port 0, Parameter 30 (classId=0x93, attributeId=9).
            - Pair with set_motor_np_power_units() to ensure units are correct.
        """
        if not isinstance(newPower, (int, float)):
            raise ValueError("newPower must be a number")
        if float(newPower) <= 0.0:
            raise ValueError("newPower must be > 0")
        success = self._set_parameter(data=float(newPower), parameterNumber=30, portNumber=0, dataType="REAL", attributeId=9, classId=0x93)
        if verify:
            return (abs(float(self.get_motor_np_power()) - float(newPower)) < 0.01) and success
        return success

    def set_motor_poles(self, newPoles: int, verify: bool = False) -> bool:
        """
        Set Port 0 P31 [Motor NP Poles] to the motor’s pole count.

        Parameters:
            - newPoles (int): Number of motor poles (> 0).
            - verify (bool): If True, reads back P31 and confirms it equals newPoles.

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes DINT to Port 0, Parameter 31 (classId=0x93, attributeId=9).
        """
        if not isinstance(newPoles, int):
            raise ValueError("newPoles must be an integer")
        if newPoles <= 0:
            raise ValueError("newPoles must be > 0")
        success = self._set_parameter(data=int(newPoles), parameterNumber=31, portNumber=0, dataType="DINT", attributeId=9, classId=0x93)
        if verify:
            return (int(self.get_motor_poles()) == int(newPoles)) and success
        return success

    def set_speed_ref_a_sp(self, rpmValue: float, verify: bool = False) -> bool:
        """
        Set Port 0 P546 [Ref A Setpt] (Speed Reference A setpoint).

        Parameters:
            - rpmValue (float) : Desired Reference A setpoint in drive speed units (e.g., RPM).
            - verify (bool) : If True, reads back P546 and confirms it equals rpmValue.

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes CIP REAL to Port 0, Parameter 546 (classId=0x93, attributeId=9).
            - The active reference source is selected elsewhere (Logic/Ref source selection).
        """
        if not isinstance(rpmValue, (int, float)):
            raise ValueError("rpmValue must be a number")
        success = self._set_parameter(data=float(rpmValue), parameterNumber=546, portNumber=0, dataType="REAL", attributeId=9, classId=0x93)
        if verify:
            return (float(self.get_speed_ref_a_sp()) == float(rpmValue)) and success
        return success

    def set_speed_ref_a_sel(self, sourceCode: int) -> bool:
        """
        Write Port 0 P545 [Speed Ref A Sel].
        Parameters:
            sourceCode (int): SSPPP code (e.g., 546 to select Port0:P546).
        Returns:
            bool: True if write succeeded.
        """
        return self._set_parameter(data=int(sourceCode), parameterNumber=545, portNumber=0, dataType="DINT", attributeId=0x09)

    def set_speed_ref_b_sp(self, rpmValue: float, verify: bool = False) -> bool:
        """
        Set Port 0 P551 [Ref B Setpt] (Speed Reference B setpoint).

        Parameters:
            - rpmValue (float): Desired Reference B setpoint in drive speed units (e.g., RPM).
            - verify (bool): If True, reads back P551 and confirms it equals rpmValue.

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes CIP REAL to Port 0, Parameter 551 (classId=0x93, attributeId=9).
        """
        if not isinstance(rpmValue, (int, float)):
            raise ValueError("rpmValue must be a number")
        success = self._set_parameter(data=float(rpmValue), parameterNumber=551, portNumber=0, dataType="REAL", attributeId=9, classId=0x93)
        if verify:
            return (float(self.get_speed_ref_b_sp()) == float(rpmValue)) and success
        return success

    def set_speed_ref_b_sel(self, sourceCode: int) -> bool:
        """
        Write Port 0 P550 [Speed Ref B Sel].
        Parameters:
            sourceCode (int): SSPPP code (e.g., 550 to select Port0:P550).
        Returns:
            bool: True if write succeeded.
        """
        return self._set_parameter(data=int(sourceCode), parameterNumber=550, portNumber=0, dataType="DINT", attributeId=0x09)

    def set_msg_control_timeout(self, seconds: int = 2, verify: bool = False) -> bool:
        """
        Set the Assembly Object (class 0x04) Control Timeout.

        Parameters:
            - seconds (int): Timeout value in seconds (0..65535).
            - verify (bool): If True, reads back Attribute 100 and confirms it equals seconds.

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes UINT to Attribute 100 (Set Control Timeout) of classId=0x04, instanceBase=0.
            - Governs how long control is retained without I/O updates (adapter-dependent behavior).
        """
        if not isinstance(seconds, int) or not (0 <= seconds <= 65535):
            raise ValueError("seconds must be an integer 0..65535")
        success = self._set_parameter(data=int(seconds), parameterNumber=0, instanceBase=0, dataType="UINT", attributeId=100, classId=0x04)
        if verify:
            return (self.get_msg_control_timeout() == seconds) and success
        return success

    def set_register_control_timeout(self, seconds: int = 2, verify: bool = False) -> bool:
        """
        Set the Register Object (class 0x07) Control Timeout.

        Parameters:
            - seconds (int): Timeout value in seconds (0..65535).
            - verify (bool): If True, reads back Attribute 100 and confirms it equals seconds.

        Returns:
            - bool: True if the write succeeded and (if verify=True) the readback matched.

        Notes:
            - Writes UINT to Attribute 100 (Set Control Timeout) of classId=0x07, instanceBase=0.
            - Applies to the Register Object’s command/feedback control window.
        """
        if not isinstance(seconds, int) or not (0 <= seconds <= 65535):
            raise ValueError("seconds must be an integer 0..65535")
        success = self._set_parameter(data=int(seconds), parameterNumber=0, instanceBase=0, dataType="UINT", attributeId=100, classId=0x07)
        if verify:
            return (self.get_register_control_timeout() == int(seconds)) and success
        return success

    def set_msg_fault_action(self, action: int, portNumber: int = 4, verify: bool = True) -> int:
        """
        Write Host Parameter P36 [Msg Flt Action] for the adapter in portNumber.

        Parameters:
            - action (int) : 0=Fault, 1=Stop, 2=Zero Data, 3=Hold Last.
            - portNumber (int) : Host port of the adapter (default 4).
            - verify (bool) : If True, read back to confirm.

        Returns:
            - bool: True if write (and optional verify) succeeded.

        Notes:
            - Host DPI class 0x9F, instance = 0x4000 + (port*0x400) + 36, attribute 0x09. :contentReference[oaicite:87]{index=87} :contentReference[oaicite:88]{index=88}
            - Action semantics per ENETR. :contentReference[oaicite:89]{index=89}
        """
        if action not in (0, 1, 2, 3, 4):
            raise ValueError("action must be one of: 0,1,2,3,4")
        if not isinstance(portNumber, int) or portNumber < 1 or portNumber > 14:
            raise ValueError("portNumber must be an int in 1..14 (ENETR typically 4, 5, or 6)")
        success = self._set_parameter(
            data=int(action), parameterNumber=36, portNumber=portNumber, dataType="DINT", attributeId=0x09, classId=0x9F
        )
        if verify:
            return (self.get_msg_fault_action(portNumber=portNumber) == action) and success
        return success

    def set_idle_fault_action(self, action: int, portNumber: int = 4, verify: bool = True) -> bool:
        """
        Write Host Parameter P34 [Idle Flt Action] for the adapter in portNumber.

        Parameters:
            - action (int) : 0=Fault, 1=Stop, 2=Zero Data, 3=Hold Last.
            - portNumber (int) : Host port of the adapter (default 4).
            - verify (bool) : If True, read back to confirm.

        Returns:
            - bool: True if write (and optional verify) succeeded.

        Notes:
            - Host DPI class 0x9F, instance base math and attribute as above. :contentReference[oaicite:90]{index=90}
            - Action semantics per ENETR. :contentReference[oaicite:91]{index=91}
        """
        if action not in (0, 1, 2, 3, 4):
            raise ValueError("action must be one of: 0,1,2,3,4")
        if not isinstance(portNumber, int) or not (1 <= portNumber <= 14):
            raise ValueError("portNumber must be 1..14")
        success = self._set_parameter(
            data=int(action), parameterNumber=34, portNumber=portNumber, dataType="DINT", attributeId=0x09, classId=0x9F
        )
        if verify:
            return (self.get_idle_fault_action(portNumber) == action) and success
        return success

    def set_comm_fault_action(self, action: int, portNumber: int = 4, verify: bool = True) -> bool:
        """
        Write Host Parameter P34 [Idle Flt Action] for the adapter in portNumber.

        Parameters:
            - action (int): 0=Fault, 1=Stop, 2=Zero Data, 3=Hold Last.
            - portNumber (int): Host port of the adapter (default 4).
            - verify (bool): If True, read back to confirm.

        Returns:
            - bool: True if write (and optional verify) succeeded.

        Notes:
            - Host DPI class 0x9F, instance base math and attribute as above. :contentReference[oaicite:90]{index=90}
            - Action semantics per ENETR. :contentReference[oaicite:91]{index=91}
        """
        if action not in (0, 1, 2, 3, 4):
            raise ValueError("action must be one of: 0,1,2,3,4")
        if not isinstance(portNumber, int) or not (1 <= portNumber <= 14):
            raise ValueError("hostPortNumber must be 1..14")
        success = self._set_parameter(
            data=int(action), parameterNumber=35, portNumber=portNumber, dataType="DINT", attributeId=0x09, classId=0x9F
        )
        if verify:
            return (self.get_comm_fault_action(portNumber) == action) and success
        return success

    def set_clear_fault(self, verify: bool = False) -> bool:
        """
        Pulse the 'Clear Fault' bit via masked Logic Command.

        Parameters:
            - verify (bool) : If True, reads Logic Status 'Fault Present' bit to confirm clear.

        Returns:
            - bool: True if the set and clear pulses succeeded (and verify passes).

        Notes:
            - Pulses CMD_BIT_CLEAR_FAULT using two writes (set then clear). Logic bit usage
            per ENETR Appendix D (Logic Command/Status). :contentReference[oaicite:43]{index=43} :contentReference[oaicite:44]{index=44}
        """
        setCmd = 1 << self.CMD_BIT_CLEAR_FAULT
        setMask = 1 << self.CMD_BIT_CLEAR_FAULT
        clrCmd = 0
        clrMask = 1 << self.CMD_BIT_CLEAR_FAULT

        if not self.set_logic_command(setCmd):
            return False
        if not self.set_logic_command(clrCmd):
            return False

        if verify:
            faultBit = self._get_logic_status_bit(7)
            return faultBit == False

        return True

    def set_clear_fault_queue(self, verify: bool = False) -> bool:
        """
        Clear the drive fault/event queue via the DPI Fault Object.

        Parameters:
            - verify (bool): If True, confirms the queue now contains a single 'clear' or 'power up' entry.

        Returns:
            - bool: True if the clear command completed (and verify passes).

        Notes:
            - Writes class 0x97 Attribute 3 (USINT) value 2 to clear the queue (per object table).
            Method then checks for a clear/power‑up entry. :contentReference[oaicite:79]{index=79} :contentReference[oaicite:80]{index=80}
        """
        # USINT value 2 = Clear Fault/Event Queue
        valueBytes = bytes([2])
        success = self._set_parameter(data=valueBytes, parameterNumber=0, portNumber=0, dataType="BYTES", attributeId=3, classId=0x97)
        if verify:
            # When clearing the fault queue, a clear event populates it, not really a failure
            lastFault = self.get_fault(1)
            return (
                lastFault["code"] in (self.DRIVE_POWER_UP_FC, self.CLEAR_FAULT_QUEUE_FC) and self.get_fault_queue_count() == 1 and success
            )

        return success

    def set_module_ptc_cfg(self, modeCode: int, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Write Port n P40 [Module PTC Cfg] — 22-Series I/O module configuration.

        Parameters:
            - modeCode (int): Supported mode/config code per the 22-Series module.
            - portNumber (int): I/O module port (default 5).
            - verify (bool): If True, read back P40 to confirm.

        Returns:
            - bool: True if the write (and optional verify) succeeded.

        Notes:
            - Validate modeCode against the table for your exact 22-Series option.
        """
        if modeCode not in (0, 1, 2, 3, 4, 5):
            raise ValueError("modeCode must be one of: 0,1,2,3,4,5")
        if not isinstance(portNumber, int) or portNumber < 1 or portNumber > 14:
            raise ValueError("hostPortNumber must be an int in 1..14 (ENETR typically 4, 5, or 6)")
        success = self._set_parameter(
            data=int(modeCode), parameterNumber=40, portNumber=portNumber, dataType="DINT", attributeId=0x0A, classId=0x9F
        )
        return (self.get_module_ptc_cfg(portNumber) == int(modeCode) and success) if verify else success

    def set_module_analog_input_0_scaling(self, upperLimit: float, lowerLimit: float, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Set AI0 scaling (P51 high, P52 low) on a 22-Series I/O module.

        Parameters:
            - upperLimit (float): Engineering high limit for AI0.
            - lowerLimit (float): Engineering low limit for AI0.
            - portNumber (int): I/O module port (default 5).
            - verify (bool): If True, read back P51/P52 to confirm.

        Returns:
            - bool: True if both writes (and optional verify) succeeded.

        Notes:
            - Validate against the configured AI0 mode (voltage/current) and the module’s rated range.
        """
        mode = self.get_module_analog_input_0_mode(portNumber=portNumber)
        if mode.upper() == "VOLTAGE" and (upperLimit > 10 or lowerLimit < -10):
            raise ValueError(
                f"Limits out of range, acceptable voltage mode range is +-10V, upperLimit: {upperLimit} and lowerLimit: {lowerLimit} is not a valid configuration"
            )
        if mode.upper() == "CURRENT" and (upperLimit > 20 or lowerLimit < 0):
            raise ValueError(
                f"Limits out of range, acceptable current mode range is 0-20mA, upperLimit: {upperLimit} and lowerLimit: {lowerLimit} is not a valid configuration"
            )
        # Upper Scaling limit
        successUpper = self._set_parameter(
            data=float(upperLimit), parameterNumber=51, portNumber=portNumber, dataType="REAL", attributeId=0x0A, classId=0x9F
        )
        # Lower Scaling limits
        sucessLower = self._set_parameter(
            data=float(lowerLimit), parameterNumber=52, portNumber=portNumber, dataType="REAL", attributeId=0x0A, classId=0x9F
        )
        if verify:
            return (
                abs(self.get_module_analog_input_0_upper_scaling(portNumber=portNumber) - float(upperLimit)) < 1e-6
                and abs(self.get_module_analog_input_0_lower_scaling(portNumber=portNumber) - float(lowerLimit)) < 1e-6
                and (successUpper and sucessLower)
            )
        return successUpper and sucessLower

    def set_module_analog_input_1_scaling(self, upperLimit: float, lowerLimit: float, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Set AI1 scaling (P61 high, P62 low) on a 22-Series I/O module.

        Parameters:
            - upperLimit (float) : Engineering high limit for AI1.
            - lowerLimit (float) : Engineering low limit for AI1.
            - portNumber (int) : I/O module port (default 5).
            - verify (bool) : If True, read back P61/P62 to confirm.

        Returns:
            - bool: True if both writes (and optional verify) succeeded.

        Notes:
            - Validate against the configured AI1 mode (voltage/current) and the module’s rated range.
        """
        mode = self.get_module_analog_input_1_mode(portNumber=portNumber)
        if mode.upper() == "VOLTAGE" and (upperLimit > 10 or lowerLimit < -10):
            raise ValueError(
                f"Limits out of range, acceptable voltage mode range is +-10V, upperLimit: {upperLimit} and lowerLimit: {lowerLimit} is not a valid configuration"
            )
        if mode.upper() == "CURRENT" and (upperLimit > 20 or lowerLimit < 0):
            raise ValueError(
                f"Limits out of range, acceptable current mode range is 0-20mA, upperLimit: {upperLimit} and lowerLimit: {lowerLimit} is not a valid configuration"
            )
        # Upper Scaling limit
        successUpper = self._set_parameter(
            data=float(upperLimit), parameterNumber=61, portNumber=portNumber, dataType="REAL", attributeId=0x0A, classId=0x9F
        )
        # Lower Scaling limits
        sucessLower = self._set_parameter(
            data=float(lowerLimit), parameterNumber=62, portNumber=portNumber, dataType="REAL", attributeId=0x0A, classId=0x9F
        )
        if verify:
            return (
                abs(self.get_module_analog_input_1_upper_scaling(portNumber=portNumber) - float(upperLimit)) < 1e-6
                and abs(self.get_module_analog_input_1_lower_scaling(portNumber=portNumber) - float(lowerLimit)) < 1e-6
                and (successUpper and sucessLower)
            )

    def set_module_analog_output_0_value(self, dataValue: float, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Write AO0 direct value path (Port n:P77) on a 22-Series module.

        Parameters:
            - dataValue (float): Desired output value.
            - portNumber (int): I/O module port (default 5).
            - verify (bool): If True, read back P82 [Anlg Out0 Val] as a consistency check.

        Returns:
            - bool: True if the write (and optional verify) succeeded.

        Notes:
            - Use when P75 selects the Value/Data path rather than the setpoint.
        """
        try:
            self.set_msg_control_timeout(3, verify=False)
        except Exception:
            pass  # best effort
        success = self._set_parameter(
            data=float(dataValue), parameterNumber=77, portNumber=portNumber, dataType="REAL", attributeId=0x0A, classId=0x9F
        ) or self._set_parameter(
            data=float(dataValue), parameterNumber=77, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F
        )
        if verify:
            # Best-effort verification via readback value
            readback = self.get_module_analog_output_0_value(portNumber=portNumber)
            # We can’t promise exact equality (filters/limits), so just ensure it’s “consistent”:
            if dataValue == 0.0:
                return success  # zero is often clamped; accept the write
            return success and (abs(readback) > 0.0)
        return success

    def set_module_analog_output_0_voltage_mode(self, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Set AO0 to Voltage mode on a 22-Series module (clear bit0 of P70).

        Parameters:
            - portNumber (int) : I/O module port (default 5).
            - verify (bool) : If True, read back P70 and confirm.

        Returns:
            - bool: True if the write (and optional verify) succeeded.

        Notes:
            - Only the AO0 bit is updated to avoid disturbing AO1 configuration.
        """
        # read current word and update only AO0 bit (bit0)
        word = int(self._get_parameter(parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F))
        newWord = word & ~0x0001  # clear bit0

        # write with 0x0A, fallback to 0x09
        success = self._set_parameter(
            data=int(newWord), parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x0A, classId=0x9F
        ) or self._set_parameter(
            data=int(newWord), parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F
        )

        if verify:
            return self.get_module_analog_output_0_mode(portNumber) == ("VOLTAGE") and success
        return success

    def set_module_analog_output_0_current_mode(self, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Set AO0 to Current mode on a 22-Series module (set bit0 of P70).

        Parameters:
            - portNumber (int): I/O module port (default 5).
            - verify (bool): If True, read back P70 and confirm.

        Returns:
            - bool: True if the write (and optional verify) succeeded.

        Notes:
            - Use ranges compatible with the module’s current output rating (e.g., 4–20 mA).
        """
        # read current word and update only AO0 bit (bit0)
        word = int(self._get_parameter(parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F))
        newWord = word | 0x0001  # set bit0

        # write with 0x0A, fallback to 0x09
        success = self._set_parameter(
            data=int(newWord), parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x0A, classId=0x9F
        ) or self._set_parameter(
            data=int(newWord), parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F
        )

        if verify:
            return self.get_module_analog_output_0_mode(portNumber) == ("CURRENT") and success
        return success

    def set_module_analog_output_0_limits(self, lowerLimit: float, upperLimit: float, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Set AO0 clamps (P81 low, P80 high) on a 22-Series module.

        Parameters:
            - lowerLimit (float): Engineering low clamp.
            - upperLimit (float): Engineering high clamp.
            - portNumber (int): I/O module port (default 5).
            - verify (bool): If True, read back P80/P81 to confirm.

        Returns:
            - bool: True if both writes (and optional verify) succeeded.

        Notes:
            - Validate against the AO0 mode and the module’s rated limits.
        """
        if not isinstance(lowerLimit, (int, float)) or not isinstance(upperLimit, (int, float)):
            raise ValueError("lowerLimit and upperLimit must be numbers")
        mode = self.get_module_analog_output_0_mode(portNumber=portNumber)
        if mode.upper() == "VOLTAGE" and (upperLimit > 10 or lowerLimit < -10):
            raise ValueError(
                f"Limits out of range, acceptable voltage mode range is +-10V, upperLimit: {upperLimit} and lowerLimit: {lowerLimit} is not a valid configuration"
            )
        if mode.upper() == "CURRENT" and (upperLimit > 20 or lowerLimit < 4):
            raise ValueError(
                f"Limits out of range, acceptable current mode range is 4-20mA, upperLimit: {upperLimit} and lowerLimit: {lowerLimit} is not a valid configuration"
            )
        # Write high then low; try 0x0A, fall back to 0x09
        successUpper = self._set_parameter(
            data=upperLimit, parameterNumber=80, portNumber=portNumber, dataType="REAL", attributeId=0x0A, classId=0x9F
        ) or self._set_parameter(
            data=upperLimit, parameterNumber=80, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F
        )
        successLower = self._set_parameter(
            data=lowerLimit, parameterNumber=81, portNumber=portNumber, dataType="REAL", attributeId=0x0A, classId=0x9F
        ) or self._set_parameter(
            data=lowerLimit, parameterNumber=81, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F
        )
        if verify:
            return (abs(self.get_module_analog_output_0_lower_limit() - lowerLimit) <= 1e-4) and (
                abs(self.get_module_analog_output_0_upper_limit() - upperLimit) <= 1e-4
            )
        return successUpper and successLower

    def set_module_analog_output_0_source_select(self, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Point AO0 source (Port n:P75) at the local setpoint (Port n:P76) on a 22-Series module.

        Parameters:
            - portNumber (int): I/O module port (default 5).
            - verify (bool): If True, read back P75 and confirm it equals (portNumber*10000 + 76).

        Returns:
            - bool: True if the write (and optional verify) succeeded.

        Notes:
            - SSPPPP encoding: (10000 * Port) + Parameter.
        """
        sourceCode = (int(portNumber) * 10000) + 76  # point AO0 at its own setpoint
        # Per manual: Default for P75 is 3 (typical code for local setpoint on AO0).
        success = self._set_parameter(
            data=int(sourceCode), parameterNumber=75, portNumber=portNumber, dataType="DINT", attributeId=0x09, classId=0x9F
        )
        if verify:
            return (self.get_module_analog_output_0_selection(portNumber) == sourceCode) and success
        return success

    def set_module_analog_output_0_setpoint(self, dataValue: float, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Write AO0 local setpoint (Port n:P76) on a 22-Series module.

        Parameters:
            - dataValue (float) : Desired setpoint in engineering units.
            - portNumber (int) : I/O module port (default 5).
            - verify (bool) : If True, verify P76 took the value and, if P75→P76, that P77 mirrors it.

        Returns:
            - bool: True if the write (and optional verify) succeeded.

        Notes:
            - Use with set_module_analog_output_0_source_select() to route AO0 from the local setpoint.
        """
        # Write the setpoint itself
        success = self._set_parameter(
            data=float(dataValue), parameterNumber=76, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F
        )
        if not success or not verify:
            return success

        # Verify P76 took the write
        rdSetpoint = float(self.get_module_analog_output_0_setpoint(portNumber=portNumber))
        ok1 = abs(rdSetpoint - float(dataValue)) < 1e-6

        # Verify P75 is actually pointing at P76 and P77 mirrors the source
        selCode = int(self.get_module_analog_output_0_selection(portNumber=portNumber))
        expectSel = (portNumber * 10000) + 76
        if selCode == expectSel:
            ok2 = abs(float(self.get_module_analog_output_0_data(portNumber=portNumber)) - float(dataValue)) < 1e-6
        else:
            # P75 is selecting a different source — in that case P77 won't match by design.
            ok2 = True  # don’t fail solely because P77 mirrors a different source

        return success and ok1 and ok2

    def set_module_analog_output_1_value(self, dataValue: float, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Write AO1 direct value path (Port n:P87) on a 22-Series module.

        Parameters:
            - dataValue (float) : Desired output value.
            - portNumber (int) : I/O module port (default 5).
            - verify (bool) : If True, read back P92 [Anlg Out1 Val] as a consistency check.

        Returns:
            - bool: True if the write (and optional verify) succeeded.
        """
        success = self._set_parameter(
            data=float(dataValue), parameterNumber=87, portNumber=portNumber, dataType="REAL", attributeId=0x0A, classId=0x9F
        )
        if verify:
            # Best-effort verification via readback value
            readback = self.get_module_analog_output_1_value(portNumber=portNumber)
            # We can’t promise exact equality (filters/limits), so just ensure it’s “consistent”:
            if dataValue == 0.0:
                return success  # zero is often clamped; accept the write
            return success and (abs(readback) > 0.0)
        return success

    def set_module_analog_output_1_voltage_mode(self, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Set AO1 to Voltage mode on a 22-Series module (clear bit1 of P70).

        Parameters:
            - portNumber (int) : I/O module port (default 5).
            - verify (bool) : If True, read back P70 and confirm.

        Returns:
            - bool: True if the write (and optional verify) succeeded.
        """
        word = int(self._get_parameter(parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F))
        newWord = word & ~0x0002  # clear bit1
        success = self._set_parameter(
            data=newWord, parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x0A, classId=0x9F
        ) or self._set_parameter(data=newWord, parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F)

        if verify:
            return self.get_module_analog_output_1_mode(portNumber) == ("VOLTAGE") and success
        return success

    def set_module_analog_output_1_current_mode(self, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Set AO1 to Current mode on a 22-Series module (set bit1 of P70).

        Parameters:
            - portNumber (int) : I/O module port (default 5).
            - verify (bool) : If True, read back P70 and confirm.

        Returns:
            - bool: True if the write (and optional verify) succeeded.
        """
        word = int(self._get_parameter(parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F))
        newWord = word | 0x0002  # set bit1
        success = self._set_parameter(
            data=newWord, parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x0A, classId=0x9F
        ) or self._set_parameter(data=newWord, parameterNumber=70, portNumber=portNumber, dataType="UINT", attributeId=0x09, classId=0x9F)
        if verify:
            return self.get_module_analog_output_1_mode(portNumber) == ("CURRENT") and success
        return success

    def set_module_analog_output_1_limits(self, lowerLimit: float, upperLimit: float, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Set AO1 clamps (P91 low, P90 high) on a 22-Series module.

        Parameters:
            - lowerLimit (float) : Engineering low clamp.
            - upperLimit (float) : Engineering high clamp.
            - portNumber (int) : I/O module port (default 5).
            - verify (bool) : If True, read back P90/P91 to confirm.

        Returns:
            - bool: True if both writes (and optional verify) succeeded.
        """
        if not isinstance(lowerLimit, (int, float)) or not isinstance(upperLimit, (int, float)):
            raise ValueError("lowerLimit and upperLimit must be numbers")
        mode = self.get_module_analog_output_1_mode(portNumber=portNumber)
        if mode.upper() == "VOLTAGE" and (upperLimit > 10 or lowerLimit < -10):
            raise ValueError(
                f"Limits out of range, acceptable voltage mode range is +-10V, upperLimit: {upperLimit} and lowerLimit: {lowerLimit} is not a valid configuration"
            )
        if mode.upper() == "CURRENT" and (upperLimit > 20 or lowerLimit < 4):
            raise ValueError(
                f"Limits out of range, acceptable current mode range is 4-20mA, upperLimit: {upperLimit} and lowerLimit: {lowerLimit} is not a valid configuration"
            )
        # Write high then low; try 0x0A, fall back to 0x09
        successUpper = self._set_parameter(
            data=upperLimit, parameterNumber=90, portNumber=portNumber, dataType="REAL", attributeId=0x0A, classId=0x9F
        ) or self._set_parameter(
            data=upperLimit, parameterNumber=90, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F
        )
        successLower = self._set_parameter(
            data=lowerLimit, parameterNumber=91, portNumber=portNumber, dataType="REAL", attributeId=0x0A, classId=0x9F
        ) or self._set_parameter(
            data=lowerLimit, parameterNumber=91, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F
        )
        if verify:
            return (abs(self.get_module_analog_output_1_lower_limit() - lowerLimit) <= 1e-4) and (
                abs(self.get_module_analog_output_1_upper_limit() - upperLimit) <= 1e-4
            )
        return successUpper and successLower

    def set_module_analog_output_1_source_select(self, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Point AO1 source (Port n:P85) at the local setpoint (Port n:P86) on a 22-Series module.

        Parameters:
            - portNumber (int) : I/O module port (default 5).
            - verify (bool) : If True, read back P85 and confirm it equals (portNumber*10000 + 86).

        Returns:
            - bool: True if the write (and optional verify) succeeded.
        """
        sourceCode = (int(portNumber) * 10000) + 86  # point AO0 at its own setpoint
        # Per manual: Default for P75 is 3 (typical code for local setpoint on AO0).
        success = self._set_parameter(
            data=int(sourceCode), parameterNumber=85, portNumber=portNumber, dataType="DINT", attributeId=0x09, classId=0x9F
        )
        if verify:
            return (self.get_module_analog_output_1_selection(portNumber) == sourceCode) and success
        return success

    def set_module_analog_output_1_setpoint(self, dataValue: float, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Write AO1 local setpoint (Port n:P86) on a 22-Series module.

        Parameters:
            - dataValue (float): Desired setpoint in engineering units.
            - portNumber (int): I/O module port (default 5).
            - verify (bool): If True, verify P86 took the value and, if P85→P86, that P87 mirrors it.

        Returns:
            - bool: True if the write (and optional verify) succeeded.
        """
        # Write the setpoint itself
        success = self._set_parameter(
            data=float(dataValue), parameterNumber=86, portNumber=portNumber, dataType="REAL", attributeId=0x09, classId=0x9F
        )
        if not success or not verify:
            return success

        # Verify P76 took the write
        rdSetpoint = float(self.get_module_analog_output_1_setpoint(portNumber=portNumber))
        ok1 = abs(rdSetpoint - float(dataValue)) < 1e-6

        # Verify P75 is actually pointing at P76 and P77 mirrors the source
        selCode = int(self.get_module_analog_output_1_selection(portNumber=portNumber))
        expectSel = (portNumber * 10000) + 76
        if selCode == expectSel:
            ok2 = abs(float(self.get_module_analog_output_1_data(portNumber=portNumber)) - float(dataValue)) < 1e-6
        else:
            # P75 is selecting a different source — in that case P77 won't match by design.
            ok2 = True  # don’t fail solely because P77 mirrors a different source

        return success and ok1 and ok2

    def set_logic_mask(self, maskValue: int) -> bool:
        """
        Write Port 0 P324 [Logic Mask].
        Parameters:
            maskValue (int): Bitmask; set bit 4 to allow Port 4 (ENETR) commands.
        Returns:
            bool: True if write succeeded.
        """
        return self._set_parameter(data=int(maskValue), parameterNumber=324, portNumber=4, dataType="INT", attributeId=0x09)

    def set_auto_mask(self, maskValue: int) -> bool:
        """
        Write Port 0 P325 [Auto Mask].
        Parameters:
            maskValue (int): Bitmask; set bit 4 to allow Port 4 (ENETR) in Auto.
        Returns:
            bool: True if write succeeded.
        """
        return self._set_parameter(data=int(maskValue), parameterNumber=325, portNumber=4, dataType="INT", attributeId=0x09)

    def set_logic_command(self, commandDword: int, verify: bool = True) -> bool:
        """
        Write the 32-bit Logic Command word (Register Object, Instance 40).

        Parameters:
        commandDword (int): Full command word to write (UDINT).
        verify (bool): If True, reads Logic Command back and compares.

        Returns:
        bool: True if the write (and optional verify) succeeded.

        Notes:
        - Instance 40 = Logic Command (R/W). Attr 4 = Data (UDINT). :contentReference[oaicite:3]{index=3}
        - Attr 2 (Direction) should be 1=Consumer for writes; Attr 3 (Size) should be 32 bits. :contentReference[oaicite:4]{index=4}
        """
        # Direction (0=Producer, 1=Consumer) — must be Consumer to write
        directionBytes = self._get_parameter(parameterNumber=0, portNumber=0, dataType="RAW", attributeId=2, classId=0x07, instanceBase=40)
        if int(directionBytes[0]) != 1:
            raise RuntimeError("Register 0x07/40 not Consumer (Direction != 1).")

        # Size should be 32 bits for Logic Command
        sizeBytes = self._get_parameter(parameterNumber=0, portNumber=0, dataType="RAW", attributeId=3, classId=0x07, instanceBase=40)
        sizeBits = int.from_bytes(sizeBytes[:2], "little", signed=False)
        if sizeBits != 32:
            raise RuntimeError(f"Register 0x07/40 size {sizeBits} bits (expected 32).")

        # Write UDINT (little-endian CIP)
        # You can also send "DINT/UDINT" directly; BYTES keeps it explicit.
        payload = struct.pack("<I", int(commandDword))
        ok = self._set_parameter(
            data=payload,
            parameterNumber=0,
            portNumber=0,
            dataType="BYTES",
            attributeId=4,
            classId=0x07,
            instanceBase=40,
        )
        if not ok or not verify:
            return bool(ok)

        # Read back and compare (non-strict: only compare the word)
        readBack = self.get_logic_command()
        return int(readBack) == int(commandDword)

    def set_start(self, verify: bool = True) -> bool:
        """
        Read the Logic Command word, swap bits 0 and 1, then write it back.

        This function retrieves the current Logic Command word, swaps bit 0 to position 1
        and bit 1 to position 0, then writes the modified word back.

        Parameters:
            verify (bool): If True, reads Logic Command back and compares after write.

        Returns:
            bool: True if the operation (and optional verify) succeeded.

        Notes:
            - Complements the set_logic_command function by providing bit manipulation.
            - Uses get_logic_command to retrieve current state before modification.
        """
        # Get current Logic Command word
        currentCommand = self.get_logic_command()

        # Clear bits 0 and 1 in the original word
        modifiedCommand = currentCommand & ~((1 << self.CMD_BIT_START) | (1 << self.CMD_BIT_STOP))

        # Flip Bits
        modifiedCommand |= 0 << self.CMD_BIT_STOP
        modifiedCommand |= 1 << self.CMD_BIT_START

        # Write the modified command back
        success = self.set_logic_command(modifiedCommand, verify)
        if verify:
            return success and self.get_active()
        return success

    def set_stop(self, verify: bool = False) -> bool:
        """
        Issue a Stop command using the masked Logic Command.

        Parameters:
            - verify (bool) : If True, confirm 'Stopping' via Logic Status.

        Returns:
            - bool: True on success (and verify passes, if requested).

        Notes:
            - Sets CMD_BIT_STOP and masks CMD_BIT_STOP only; see method body and ENETR Register Object. :contentReference[oaicite:41]{index=41} :contentReference[oaicite:42]{index=42}
        """
        # Get current Logic Command word
        currentCommand = self.get_logic_command()

        # Clear bits 0 and 1 in the original word
        modifiedCommand = currentCommand & ~((1 << self.CMD_BIT_START) | (1 << self.CMD_BIT_STOP) | (1 << self.CMD_BIT_RUN))

        # Flip bits
        modifiedCommand |= 1 << self.CMD_BIT_STOP
        modifiedCommand |= 0 << self.CMD_BIT_START
        modifiedCommand |= 0 << self.CMD_BIT_RUN
        # Write the modified command back
        success = self.set_logic_command(modifiedCommand, verify)
        if verify:
            return success and self.get_stopping()
        return success

    def set_forward(self, verify: bool = False) -> bool:
        """
        Command forward direction (DIR=01) using the masked Logic Command.

        Parameters:
            - verify (bool) : If True, check 'Command Direction' bit in Logic Status.

        Returns:
            - bool: True if the write succeeded (and verify passes).

        Notes:
            - Updates DIR_LO/Hl pair by mask so only direction is changed. :contentReference[oaicite:45]{index=45}
        """
        currentCommand = self.get_logic_command()
        # Clear bits 4 and 5 first (direction bits)
        modifiedCommand = currentCommand & ~((1 << self.CMD_BIT_DIR_LO) | (1 << self.CMD_BIT_DIR_HI))

        # Set forward direction: bit 5 = 0 (already cleared), bit 4 = 1
        modifiedCommand |= 0 << self.CMD_BIT_DIR_HI  # Set bit 5 to 0
        modifiedCommand |= 1 << self.CMD_BIT_DIR_LO  # Set bit 4 to 1
        # Bit 5 stays 0 (already cleared above)

        # Write the modified command back
        success = self.set_logic_command(modifiedCommand, verify)
        if verify:
            return success and self.get_command_direction()
        return success

    def set_reverse(self, verify: bool = True) -> bool:
        """
        Set reverse direction by configuring DIR bits [5:4] = 10 (reverse).

        This function retrieves the current Logic Command word, sets bit 4 to 0 and bit 5 to 1
        for reverse direction control, then writes the modified word back.

        Parameters:
            verify (bool): If True, reads Logic Command back and compares after write.

        Returns:
            bool: True if the operation (and optional verify) succeeded.

        Notes:
            - Sets DIR bits [5:4] = 10 for reverse direction (bit 4=0, bit 5=1)
            - Uses get_logic_command to retrieve current state before modification.
            - CMD_BIT_DIR_LO = 4, CMD_BIT_DIR_HI = 5 (direction control bits)
        """
        # Get current Logic Command word
        currentCommand = self.get_logic_command()

        # Clear bits 4 and 5 first (direction bits)
        modifiedCommand = currentCommand & ~((1 << self.CMD_BIT_DIR_LO) | (1 << self.CMD_BIT_DIR_HI))

        # Set reverse direction: bit 4 = 0 (already cleared), bit 5 = 1
        modifiedCommand |= 1 << self.CMD_BIT_DIR_HI  # Set bit 5 to 1
        modifiedCommand |= 0 << self.CMD_BIT_DIR_LO  # Set bit 4 to 0
        # Bit 4 stays 0 (already cleared above)

        # Write the modified command back
        success = self.set_logic_command(modifiedCommand, verify)
        if verify:
            return success and not self.get_command_direction()
        return success

    def set_speed_units(self, RPM: bool = True, verify: bool = True) -> bool:
        """
        Set speed units to RPM or Hz via Logic Command bits. If RPM is True, set to RPM; if False set to Hz.

        Parameters:
            RPM (bool): If True, set units to RPM; if False, set to Hz.
            verify (bool): If True, reads Logic Command back and compares after write.
        """
        success = self._set_parameter(data=(1 if RPM else 0), parameterNumber=300, portNumber=0, dataType="DINT", attributeId=0x09)
        if verify:
            speedUnit = self.get_speed_units()
            print(speedUnit)
            print(speedUnit == ("RPM" if RPM else "Hz"))
            print(success)
            print(success and (speedUnit == ("RPM" if RPM else "Hz")))
            return success and (speedUnit == ("RPM" if RPM else "Hz"))
        return success

    def set_run(self, run: bool = True, verify: bool = True) -> bool:
        """
        Issue a Run command using the masked Logic Command.

        Parameters:
            verify (bool): If True, confirm 'Running' via Logic Status.

        Returns:
            bool: True on success (and verify passes, if requested).
        """
        # Get current Logic Command word
        currentCommand = self.get_logic_command()

        # Clear bit 18 in the original word
        modifiedCommand = currentCommand & ~((1 << self.CMD_BIT_RUN))

        # Swap the bits: flip bit18
        modifiedCommand |= (1 if run else 0) << self.CMD_BIT_RUN

        # Write the modified command back
        success = self.set_logic_command(modifiedCommand, verify)
        if verify:
            return success and self.get_running()
        return success

    ###########################
    ## Private Class Methods ##
    ###########################
    #####################
    ## Private Getters ##
    #####################
    def _get_parameter(
        self,
        parameterNumber: int,
        portNumber: int = 0,
        dataType: str = "REAL",
        attributeId: int = 0x09,
        classId: int = 0x93,
        instanceBase: Optional[int] = None,
    ) -> Union[float, int, bytes]:
        """
        Read a single parameter via CIP 'Get Attribute Single'.

        Parameters:
            - parameterNumber (int): Parameter number within the port.
            - portNumber (int): Drive port that owns the parameter (0 = drive, 4 = ENETR, 5 = I/O, etc.).
            - dataType (str): Expected data type ('REAL', 'DINT', 'UINT', 'USINT', 'BYTES', 'RAW', 'BOOL').
            - attributeId (int): Attribute to read (default 0x09 = Parameter Value).
            - classId (int): CIP class (default 0x93 DPI Parameter; use 0x9F for Host DPI).
            - instanceBase (int|None): Explicit base for instance math; if None, uses DPI_INSTANCE_OFFSET mapping.

        Returns:
            - float|int|bytes : The decoded value (or raw bytes when requested).

        Notes:
            - DPI Parameter Object (class 0x93) and Host DPI Parameter Object (class 0x9F)
            are the supported classes for explicit reads in PF 750‑Series. Instance equals
            base + parameter number. :contentReference[oaicite:5]{index=5}
            - Host DPI instance bases for ports 1…14 start at 0x4000 and step by 0x400 per port. :contentReference[oaicite:6]{index=6}
            - Example controller MSG setup for Get Attribute Single is shown in the ENETR manual;
            the same class/instance/attribute rules apply here. :contentReference[oaicite:7]{index=7}
            - See implementation and DPI_INSTANCE_OFFSET in PowerFlex753.py. :contentReference[oaicite:8]{index=8}
        """
        if not isinstance(parameterNumber, int) or parameterNumber < 0:
            raise ValueError("parameterNumber must be a positive int")

        if instanceBase is None:
            if portNumber in self.DPI_INSTANCE_OFFSET:
                instanceId = self.DPI_INSTANCE_OFFSET[portNumber] + parameterNumber
            else:
                raise NotImplementedError(
                    f"Port {portNumber} offset not defined. " f"Pass instanceBase explicitly (base + parameterNumber)."
                )
        else:
            if not isinstance(instanceBase, int) or instanceBase < 0:
                raise ValueError("instanceBase must be a non-negative int")
            instanceId = instanceBase + parameterNumber

        # Read bytes via your existing EIP client (Class 0x93 = DPI Parameter Object, Attr 9 = Value)
        rawBytesList = self.eeipClient.get_attribute_single(
            class_id=classId,
            instance_id=instanceId,
            attribute_id=attributeId,
        )

        valueBytes = bytes(rawBytesList)

        # Decode based on the expected parameter type (little-endian CIP scalars)
        # You can extend this map if you hit other types.
        typeKey = dataType.upper()
        fmtByType = self._get_format_by_type()
        if typeKey not in fmtByType:
            raise ValueError(f"Unsupported dataType '{dataType}'")

        fmt, need = fmtByType[typeKey]
        if fmt in ["RAW", "BYTES"]:
            return valueBytes
        if len(valueBytes) < need:
            raise ValueError(f"Attribute {attributeId} for instance {instanceId} returned {len(valueBytes)} byte(s); expected >= {need}")

        decoded = struct.unpack(fmt, valueBytes[:need])[0]
        # Normalize ints to Python int and floats to float
        return float(decoded) if typeKey == "REAL" else int(decoded)

    def _get_format_by_type(self) -> dict:
        """
        Internal map from a simple 'dataType' string to a struct format string and size.

        Parameters:
            - None

        Returns:
            - dict: Keys like 'REAL', 'DINT', 'UINT', 'USINT', 'BYTES', 'RAW', 'BOOL' with (format, size) pairs.

        Notes:
            - Used by `_get_parameter` and `_set_parameter` to pack/unpack values consistently.
            See implementation in PowerFlex753.py. :contentReference[oaicite:9]{index=9}
        """
        return {
            "REAL": ("<f", 4),
            "DINT": ("<i", 4),
            "UDINT": ("<I", 4),
            "DUDINT": ("<II", 8),
            "INT": ("<h", 2),
            "UINT": ("<H", 2),
            "SINT": ("<b", 1),
            "USINT": ("<B", 1),
            "BOOL": ("<?", 1),  # 1-byte boolean (0x00/0x01)
            "RAW": ("RAW", 0),  # Custom, just return the raw bytes to work with
            "BYTES": ("BYTES", 0),
        }

    def _get_logic_status_bit(self, bitIndex: int) -> bool:
        if not (0 <= bitIndex <= 31):
            raise ValueError("bitIndex must be 0..31")
        logicWord = int(self.get_logic_status())
        return bool((logicWord >> bitIndex) & 0x1)

    def _get_host_param_instance_base(self, portNumber: int) -> int:
        # Known base offsets we can prove from the manual:
        # - Port 0 (Drive/Host): instance = parameter number (typical/expected for Port 0 examples).
        # - Port 4 (E/IP Module): instance = 20480 + parameterNumber.
        # - Port 5 (IO Module): instance = 21504 + parameterNumber.
        # - Port 11 (Converter 1): instance = 27648 + parameterNumber (documented).
        #   Ref: "Instance is calculated by adding an offset of 27648 to the converter parameter" for Class 0x93, Attr 9. :contentReference[oaicite:1]{index=1}
        if portNumber < 0 or portNumber > 14:
            raise ValueError("portNumber must be 0..14 for Host DPI Parameter Object (0x9F)")
        if portNumber == 0:
            return 0
        else:
            return 16384 + (1024 * portNumber)

    #####################s
    ## Private Setters ##
    #####################
    def _set_parameter(
        self,
        data: any,
        parameterNumber: int,
        portNumber: int = 0,
        dataType: str = "REAL",
        attributeId: int = 0x09,
        classId: int = 0x93,
        instanceBase: Optional[int] = None,
    ) -> bool:
        """
        Write a single parameter via CIP 'Set Attribute Single'.

        Parameters:
            - data (Any) : Value to write (number, bytes) compatible with dataType.
            - parameterNumber (int) : Parameter number within the port.
            - portNumber (int) : Drive port that owns the parameter (0 = drive, 4 = ENETR, 5 = I/O, etc.).
            - dataType (str) : Data type ('REAL', 'DINT', 'UINT', 'USINT', 'BYTES', 'RAW', 'BOOL').
            - attributeId (int) : Attribute to write (0x09 = Value or 0x0A = RAM-only, depending on target).
            - classId (int) : CIP class (0x93 DPI Parameter, 0x9F Host DPI).
            - instanceBase (int|None) : Explicit base for instance math; if None uses DPI_INSTANCE_OFFSET.

        Returns:
            - bool: True if the adapter accepted the write without exception.

        Notes:
            - ENETR manual shows Get/Set Attribute message formatting and class/instance/attribute
            values for DPI (0x93) and Host DPI (0x9F) objects. Use 0x09 (Value) or 0x0A (RAM-only)
            per the parameter’s behavior. :contentReference[oaicite:10]{index=10}
            - Host DPI instance bases follow 0x4000 + (port*0x400). :contentReference[oaicite:11]{index=11}
            - See the method body in PowerFlex753.py for packing/size details. :contentReference[oaicite:12]{index=12}
        """
        if not isinstance(parameterNumber, int) or parameterNumber < 0:
            raise ValueError("parameterNumber must be a positive int")

        if instanceBase is None:
            if portNumber in self.DPI_INSTANCE_OFFSET:
                instanceId = self.DPI_INSTANCE_OFFSET[portNumber] + parameterNumber
            else:
                raise NotImplementedError(
                    f"Port {portNumber} offset not defined. " f"Pass instanceBase explicitly (base + parameterNumber)."
                )
        else:
            if not isinstance(instanceBase, int) or instanceBase < 0:
                raise ValueError("instanceBase must be a non-negative int")
            instanceId = instanceBase + parameterNumber
        typeKey = dataType.upper()
        fmtByType = self._get_format_by_type()
        if typeKey not in fmtByType:
            raise ValueError(f"Unsupported dataType '{dataType}'")
        fmt, need = fmtByType[typeKey]
        if fmt in ["BYTES", "RAW"]:
            valueBytes = data
        else:
            valueBytes = struct.pack(fmt, data)
        try:
            self.eeipClient.set_attribute_single(
                class_id=classId,
                instance_id=instanceId,
                attribute_id=attributeId,  # could use 0x0A for RAM-only writes; 0x09 writes normal value
                value=list(valueBytes),
            )
            return True
        except:
            return False

    def _set_module_analog_output_0_source_to_setpoint(self, portNumber: int = 5, verify: bool = False) -> bool:
        """
        Point AO0 source (Port n:P75) at its local setpoint (Port n:P76) on a 22-Series analog module.

        Parameters:
            - portNumber (int) : I/O module port hosting the analog module (default 5).
            - verify (bool) : If True, reads back P75 and confirms it equals (portNumber*10000 + 76).

        Returns:
            - bool: True if the write to P75 succeeded and (if verify=True) the readback matched.

        Notes:
            - Selection uses SSPPPP encoding: sourceCode = (10000 * Port) + Parameter.
            For AO0 setpoint, Parameter = 76 → sourceCode = (portNumber * 10000) + 76.
            - Writes Host DPI parameter P75 via CIP Set Attribute Single:
            classId=0x9F (Host DPI Parameter Object), attributeId=0x09 (Value), dataType="DINT".
            - On verify=True, reads P75 back with the same class/attribute and compares to sourceCode.
            - This config routes AO0 to follow its local setpoint (P76), not the direct Value/Data path.
        """
        sourceCode = (int(portNumber) * 10000) + 76  # point AO0 at its own setpoint
        success = self._set_parameter(
            data=int(sourceCode), parameterNumber=75, portNumber=portNumber, dataType="DINT", attributeId=0x09, classId=0x9F
        )
        if verify:
            readBack = int(self._get_parameter(parameterNumber=75, portNumber=portNumber, dataType="DINT", attributeId=0x09, classId=0x9F))
            return (readBack == sourceCode) and success
        return success

    #####################
    ## Private Helpers ##
    #####################

    def _format_ts_elapsed(self, ts: int) -> str:
        """
        Format a relative/elapsed timestamp from the Fault object into 'd HH:MM:SS.mmm'.

        Parameters:
            - ts (int) : Timestamp value reported by the DPI Fault Object (seconds/ms/us).

        Returns:
            - str: Human-readable elapsed time string.

        Notes:
            - The DPI Fault Object encodes a 'flags' field indicating whether the timestamp
            is real-time or elapsed; this formatter is used when it is elapsed. :contentReference[oaicite:13]{index=13}
        """
        # choose base unit by magnitude
        if ts >= 10**12:
            total_ms = ts / 1000.0  # microseconds -> milliseconds
        elif ts >= 10**6:
            total_ms = float(ts)  # milliseconds
        else:
            total_ms = ts * 1000.0  # seconds -> milliseconds

        ms = int(round(total_ms))
        seconds, ms = divmod(ms, 1000)
        minutes, seconds = divmod(seconds, 60)
        hours, minutes = divmod(minutes, 60)
        days, hours = divmod(hours, 24)
        return f"{days}d {hours:02d}:{minutes:02d}:{seconds:02d}.{ms:03d}"

    def _format_ts_real_time(self, ts: int) -> str:
        """
        Format a real-time (epoch-based) timestamp from the Fault object to ISO-8601 UTC.

        Parameters:
            - ts (int): Timestamp value (seconds or milliseconds since epoch).

        Returns:
            - str: ISO-8601 UTC string.

        Notes:
            - Used when the Fault Object flags indicate a 'real-time' timestamp. See fault
            decode in PowerFlex753.py. :contentReference[oaicite:14]{index=14}
        """
        try:
            if ts >= 10**12:
                return datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc).isoformat()
            else:
                return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
        except Exception:
            return str(ts)

    #####################
    ## Public Helpers ###
    #####################
    def unregister_session(self):
        """
        Close the current EtherNet/IP session with the adapter.

        Parameters:
            - None

        Returns:
            - None

        Notes:
            - Ends the ENIP session created by the underlying client. No device parameters
            are changed by this call. See class implementation in PowerFlex753.py. :contentReference[oaicite:4]{index=4}
        """
        self.eeipClient.unregister_session()
