# n6_controller.py

import time
import socket
import struct
from http.client import HTTPConnection

from .NanotecBase import NanotecBase  # base ABC

try:
    # Optional: use the same EtherNet/IP client you used for C5E11
    from eip.client import EIPBaseClient
except Exception:  # pragma: no cover
    EIPBaseClient = None


class N6Controller(NanotecBase):
    def __init__(self, deviceIpAddress: str, port: int = 44818, httpTimeout: float = 3.0):
        """
        Initialize an N6‑x‑3‑1 controller interface over EtherNet/IP.

        Parameters:
        deviceIpAddress (str): IPv4 address of the drive (e.g., "192.168.0.50").
        port (int): EtherNet/IP TCP port. Default is 44818.
        httpTimeout (float): Reserved for optional REST fallback paths (seconds). Default is 3.0.

        Attributes:
        httpTimeout (float): Timeout for any HTTP operations (if added).
        port (int): Active EtherNet/IP TCP port.
        eipClient (EIPBaseClient | None): Underlying EIP client. Created during init.
        sessionHandle (int): Registered EIP session handle.

        Raises:
        RuntimeError: If the EtherNet/IP session cannot be established.

        Notes:
        - Registers an EIP UCMM session using `EIPBaseClient.register_session(...)`.
        - Stores the session handle for encapsulated CIP transactions built by helpers like
          `_build_rr_data_packet()` and `_build_encapsulation()`.
        - This class inherits from your base ABC, so it provides concrete implementations for
          OD read/write and motion helpers expected by the base.
        """
        super().__init__(deviceIpAddress)
        self.httpTimeout = httpTimeout
        self.port = port

        # EtherNet/IP plumbing
        self.eipClient = None
        self.sessionHandle = 0
        try:
            self.eipClient = EIPBaseClient()
            self.sessionHandle = self.eipClient.register_session(self.deviceIpAddress, port=self.port)
        except Exception as e:
            raise RuntimeError(f"Failed to create EtherNet/IP session: {e}")

    ##########################
    ## Public Class Methods ##
    ##########################

    ####################
    ## Public Getters ##
    ####################

    def get_od(self, index: int, subindex: int, valueType: str = "UNSIGNED32", decode: bool = True):
        """
        Read an Object Dictionary (OD) entry via EtherNet/IP UCMM (service 0x32 on class 0x64, instance 0x01).

        Parameters:
        index (int): 16‑bit OD index to read (e.g., 0x6041).
        subindex (int): 8‑bit subindex within the object (e.g., 0x00).
        valueType (str): Target decode type. One of:
            "UNSIGNED32", "UNSIGNED16", "UNSIGNED8",
            "INTEGER32", "INTEGER16", "INTEGER8". Default "UNSIGNED32".
        decode (bool): If True, return a decoded int according to `valueType`. If False, return raw payload bytes as a list.

        Returns:
        int | list[int]: Decoded integer value if `decode=True`, else a list of raw payload bytes (little‑endian).

        Raises:
        Exception: If the response is too short or the CIP General Status is non‑zero.
        ValueError: If `valueType` is not supported by `_decode_value()` when `decode=True`.

        Notes:
        - Auto‑registers an EIP session if needed.
        - CIP path used: Class 0x64 / Instance 0x01.
        - Request data: [index(lo,hi), subindex].
        - Encapsulation built with `_build_rr_data_packet(service=0x32, ...)` and `_build_encapsulation(...)`.
        - CIP General Status is validated at byte offset 42 of the EIP payload.
        - Raw payload begins at byte offset 44; `_decode_value()` performs endian‑aware integer conversion.
        """
        valueType = valueType.upper()
        if self.eipClient.connnection_config.session_handle == 0:
            self.eipClient.connnection_config.session_handle = self.eipClient.register_session(
                self.deviceIpAddress, self.eipClient.tcp_port
            )
        path = bytes([0x20, 0x64, 0x24, 0x01])
        data = index.to_bytes(2, "little") + subindex.to_bytes(1, "little")
        rrData = self._build_rr_data_packet(service=0x32, path=path, data=data)
        packet = self._build_encapsulation(rrData)
        self.eipClient.tcp_client_socket.send(packet)
        response = self._recv_eip_response()
        if not response or len(response) < 44:
            raise Exception(f"FAILED: Response too short or missing - {response}")
        # General Status at byte 42
        if response[42] != 0x00:
            raise Exception(f"CIP Error: General Status {response[42]:02X}")
        payload = bytes(response[44:])
        if decode:
            return self._decode_value(payload, valueType)
        return list(payload)

    def get_pole_pair(self) -> int:
        """
        Read the motor's configured pole‑pair count.

        Returns:
        int: Number of pole pairs (OD 0x2030:0x00, UNSIGNED32).

        Notes:
        - Used for correct commutation and motion profile calculations within the drive.
        - Mirrors your base API contract for motor metadata.
        """
        return self.get_od(index=0x2030, subindex=0x00, valueType="unsigned32")  # :contentReference[oaicite:9]{index=9}

    def get_is_open_loop(self) -> bool:
        """
        Check whether the controller is in open‑loop mode.

        Returns:
        bool: True if open‑loop mode (bit 0 == 0), False otherwise.

        Notes:
        - Reads OD 0x3202:0x00 (UNSIGNED32) without decoding to inspect the raw LSB.
        - Open‑loop mode disables feedback control loops.
        """
        status = self.get_od(index=0x3202, subindex=0x00, valueType="unsigned32", decode=False)  # :contentReference[oaicite:10]{index=10}
        return self._check_bit(status[0], 0, 0)

    def get_is_closed_loop(self) -> bool:
        """
        Check whether the controller is in closed‑loop mode.

        Returns:
        bool: True if closed‑loop mode (bit 0 == 1), False otherwise.

        Notes:
        - Reads OD 0x3202:0x00 (UNSIGNED32) without decoding to inspect the raw LSB.
        - Closed‑loop mode enables feedback control (higher accuracy/robustness).
        """

        status = self.get_od(index=0x3202, subindex=0x00, valueType="unsigned32", decode=False)  # :contentReference[oaicite:11]{index=11}
        return self._check_bit(status[0], 0, 1)

    def get_current_reduction_settings(self) -> dict:
        """
        Get the idle current‑reduction configuration for open‑loop operation.

        Returns:
        dict: {
            "idleTimeMs": int,                # OD 0x3219:0x02 (UNSIGNED32)
            "idleCurrentTenthPercent": int,   # OD 0x3219:0x03 (UNSIGNED32), 0..1000 => 0..100%
            "idleCurrentmA": int              # Calculated from rated current (6075h) * tenthPercent/1000
        }

        Notes:
        - Rated current is read from OD 0x6075:0x00 (UNSIGNED32).
        - The reduction level is expressed in 0.1% units of rated current.
        """
        idleTimeMs = self.get_od(index=0x3219, subindex=0x02, valueType="unsigned32")
        idleTenthPercent = self.get_od(index=0x3219, subindex=0x03, valueType="unsigned32")
        ratedCurrent = self.get_od(index=0x6075, subindex=0x00, valueType="unsigned32")
        idleCurrentmA = int(round(ratedCurrent * (idleTenthPercent / 1000.0)))
        return {"idleTimeMs": idleTimeMs, "idleCurrentTenthPercent": idleTenthPercent, "idleCurrentmA": idleCurrentmA}

    def get_analog_config(self) -> dict:
        """
        Read analog input scaling configuration.

        Returns:
        dict: {
            "analog1": {"offset": int, "numerator": int, "denominator": int},
            "analog2": {"offset": int, "numerator": int, "denominator": int}
        }

        Notes:
        - Offsets:  0x3321:01 (A1), 0x3321:02 (A2)   — INTEGER16
        - Numerators: 0x3322:01 (A1), 0x3322:02 (A2) — INTEGER16
        - Denominators: 0x3323:01 (A1), 0x3323:02 (A2) — INTEGER16
        - These parameters define linear scaling: (raw + offset) * numerator / denominator.
        """
        a1Offset = self.get_od(0x3321, 0x01, "integer16")
        a2Offset = self.get_od(0x3321, 0x02, "integer16")
        a1Numer = self.get_od(0x3322, 0x01, "integer16")
        a2Numer = self.get_od(0x3322, 0x02, "integer16")
        a1Denom = self.get_od(0x3323, 0x01, "integer16")
        a2Denom = self.get_od(0x3323, 0x02, "integer16")
        return {
            "analog1": {"offset": a1Offset, "numerator": a1Numer, "denominator": a1Denom},
            "analog2": {"offset": a2Offset, "numerator": a2Numer, "denominator": a2Denom},
        }

    def get_analog_1_mode(self) -> str:
        """
        Report Analog Input 1 mode.

        Returns:
        str: Always "voltage" on N6 (mode is not switchable via OD in this interface).

        Notes:
        - Scaling is configured via 0x3321/0x3322/0x3323; raw counts are available at 0x3220:01.
        """
        return "voltage"

    def get_analog_2_mode(self) -> str:
        """
        Report Analog Input 2 mode.

        Returns:
        str: Always "voltage" on N6 (mode is not switchable via OD in this interface).
        """
        return "voltage"

    def get_analog_1_input(self) -> int:
        """
        Read Analog Input 1 raw ADC counts.

        Returns:
        int: Unsigned 16‑bit raw counts from OD 0x3220:0x01 (UNSIGNED16).

        Notes:
        - Apply scaling from `get_analog_config()` if an engineering unit is needed.
        """
        return self.get_od(index=0x3220, subindex=0x01, valueType="unsigned16")

    def get_analog_2_input(self) -> int:
        """
        Read Analog Input 2 raw ADC counts.

        Returns:
        int: Unsigned 16‑bit raw counts from OD 0x3220:0x02 (UNSIGNED16).

        Notes:
        - Apply scaling from `get_analog_config()` if an engineering unit is needed.
        """
        return self.get_od(index=0x3220, subindex=0x02, valueType="unsigned16")

    def get_digital_output(self, outputIndex: int) -> int:
        """
        Read the state of a digital output line.

        Parameters:
        outputIndex (int): 1‑based output index (OUT1=1, ...).

        Returns:
        int: 1 if active, 0 if inactive.

        Raises:
        ValueError: If `outputIndex` < 1.

        Notes:
        - OD 0x60FE:0x01 (UNSIGNED32) holds multiple digital outputs; OUT1.. are mapped
          to bits starting at bit 16. Bit 0 is reserved for the brake.
        - This method shifts/masks to extract the requested output bit.
        """
        if outputIndex < 1:
            raise ValueError("outputIndex must be >= 1")
        raw = self.get_od(index=0x60FE, subindex=0x01, valueType="unsigned32", decode=True)
        bitPos = 16 + (outputIndex - 1)
        return 1 if ((raw >> bitPos) & 0x1) else 0

    def get_torque_forming_current(self) -> int:
        """
        Read the torque‑forming current (Iq).

        Returns:
        int: Signed 32‑bit Iq value from OD 0x2039:0x02 (INTEGER32). Units per device spec.

        Notes:
        - Useful for monitoring load during motion or validating current‑reduction settings.
        """
        return self.get_od(index=0x2039, subindex=0x02, valueType="integer32")

    def get_max_voltage(self) -> int:
        """
        Read the configured maximum motor voltage.

        Returns:
        int: Millivolts from OD 0x321E:0x00 (UNSIGNED32).
        """
        return self.get_od(index=0x321E, subindex=0x00, valueType="unsigned32")

    def get_max_current(self) -> int:
        """
        Read the configured maximum motor current (converted to mA).

        Returns:
        int: Milliamps, computed from rated current (OD 0x6075:0x00, UNSIGNED32) multiplied
             by the limit percentage (OD 0x6073:0x00, UNSIGNED32, 0..1000 => 0..100%).

        Notes:
        - The drive stores the limit as a tenth‑percent of rated current; this method converts
          it to an absolute current value.
        """
        ratedCurrent = self.get_od(index=0x6075, subindex=0x00, valueType="unsigned32")
        tenthPercent = self.get_od(index=0x6073, subindex=0x00, valueType="unsigned32")
        return int(round(ratedCurrent * (tenthPercent / 1000.0)))

    def get_acceleration(self) -> int:
        """
        Read the motion profile acceleration.

        Returns:
        int: Acceleration from OD 0x6083:0x00 (UNSIGNED32). Units per device settings.
        """
        return self.get_od(index=0x6083, subindex=0x00, valueType="unsigned32")

    def get_deceleration(self) -> int:
        """
        Read the motion profile deceleration.

        Returns:
        int: Deceleration from OD 0x6084:0x00 (UNSIGNED32). Units per device settings.
        """
        return self.get_od(index=0x6084, subindex=0x00, valueType="unsigned32")

    def get_motion_profile_type(self) -> str:
        """
        Get the configured motion profile type.

        Returns:
        str: One of "trapezoidal" or "jerk_limited_ramp".

        Notes:
        - Reads OD 0x6086:0x00 (UNSIGNED32).
        - Value 0 = trapezoidal, 3 = jerk-limited ramp.
        """
        profileType = self.get_od(index=0x6086, subindex=0x00, valueType="unsigned32")
        if profileType == 0:
            return "Trapezoidal"
        elif profileType == 3:
            return "Jerk Limited Ramp"
        else:
            return "Unknown"

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

    def set_od(self, index: int, subindex: int, value: int, valueType: str = "UNSIGNED32"):
        """
        Write an Object Dictionary (OD) entry via EtherNet/IP UCMM (service 0x33 on class 0x64, instance 0x01).

        Parameters:
        index (int): 16‑bit OD index to write (e.g., 0x6040).
        subindex (int): 8‑bit subindex within the object (e.g., 0x00).
        value (int | bytes | list[int]): Value to write. Encoded per `valueType`.
        valueType (str): One of:
            "UNSIGNED32", "UNSIGNED16", "UNSIGNED8",
            "INTEGER32", "INTEGER16", "INTEGER8",
            "COMMAND" (single raw byte). Default "UNSIGNED32".

        Returns:
        bool: True if the write succeeded (CIP General Status == 0).

        Raises:
        ValueError: If `valueType` is unsupported.
        Exception: If the EIP response is too short or `General Status` is non‑zero.

        Notes:
        - Auto‑registers an EIP session if needed.
        - Payload is `[index(lo,hi), subindex, valueBytes...]` (little‑endian).
        - Encapsulation built with `_build_rr_data_packet(service=0x33, ...)` and `_build_encapsulation(...)`.
        - CIP General Status is validated at byte offset 42 of the EIP payload.
        """
        valueType = valueType.upper()
        if self.eipClient.connnection_config.session_handle == 0:
            self.eipClient.connnection_config.session_handle = self.eipClient.register_session(
                self.deviceIpAddress, self.eipClient.tcp_port
            )

        path = bytes([0x20, 0x64, 0x24, 0x01])

        # Encode value
        if valueType == "UNSIGNED32":
            valueBytes = int(value).to_bytes(4, "little", signed=False)
        elif valueType == "INTEGER32":
            valueBytes = int(value).to_bytes(4, "little", signed=True)
        elif valueType == "UNSIGNED16":
            valueBytes = int(value).to_bytes(2, "little", signed=False)
        elif valueType == "INTEGER16":
            valueBytes = int(value).to_bytes(2, "little", signed=True)
        elif valueType == "UNSIGNED8":
            valueBytes = int(value).to_bytes(1, "little", signed=False)
        elif valueType == "INTEGER8":
            valueBytes = int(value).to_bytes(1, "little", signed=True)
        elif valueType == "COMMAND":
            valueBytes = bytes([value]) if isinstance(value, int) else bytes(value)
        else:
            raise ValueError(f"Unsupported value type: {valueType}")

        data = index.to_bytes(2, "little") + subindex.to_bytes(1, "little") + valueBytes
        rrData = self._build_rr_data_packet(service=0x33, path=path, data=data)
        packet = self._build_encapsulation(rrData)
        self.eipClient.tcp_client_socket.send(packet)

        response = self._recv_eip_response()
        if not response or len(response) < 44:
            raise Exception(f"FAILED: Response too short or missing - {response}")
        if response[42] != 0x00:
            raise Exception(f"SetOD failed, General Status = {response[42]:02X}")
        return True

    def set_move(
        self,
        moveType: str = "relative",
        target: int = 5000,
        maxSpeed: int = 200,
        bypassError: bool = False,
        bypassWait: bool = False,
    ):
        """
        Execute a profile motion using the PDI command channel (OD 0x2291).

        Parameters:
        moveType (str): One of "relative", "absolute", "velocity".
        target (int): Position target for "relative"/"absolute" (device units). Default 5000.
        maxSpeed (int): Maximum speed for the move (device units). Default 200.
        bypassError (bool): If True, do not raise on timeout; exit the poll loop when timer elapses. Default False.

        Returns:
        bool: True when the command finishes normally (or when bypassing timeout).

        Raises:
        ValueError: If `moveType` is not one of the accepted options.
        RuntimeError: If the target is not reached before the internal timer elapses and `bypassError` is False.

        Notes:
        - For "relative"/"absolute": writes 0x2291:01 (target) and 0x2291:02 (max speed), then toggles 0x2291:04
          with "ProfilePosRel" or "ProfilePosAbs".
        - For "velocity": writes 0x2291:01 (targetVelocity) then toggles 0x2291:04 with "ProfileVelocity".
        - Uses `_start_timer()` and `_timer_is_done()` to enforce a movement timeout.
        - Polls `_get_target_reached()` between short sleeps until completion or timeout.
        """

        if moveType.lower() not in ["relative", "absolute", "velocity"]:
            raise ValueError("moveType must be, 'velocity', 'relative' or 'absolute'")

        if moveType.lower() == "relative":
            self._set_relative_move(targetPosition=int(target), maxSpeed=int(maxSpeed))
        elif moveType.lower() == "absolute":
            self._set_absolute_move(targetPosition=int(target), maxSpeed=int(maxSpeed))
        else:
            self._set_velocity_move(targetVelocity=int(target), maxSpeed=int(maxSpeed))

        self._start_timer()
        # controller registration delay
        time.sleep(0.1)

        while not self._get_target_reached() and not bypassWait:
            time.sleep(0.1)
            if bypassError:
                if self._timer_is_done():
                    break
            else:
                if self._timer_is_done():
                    raise RuntimeError(f"Motor failed to reach position in {self.timerDuration} seconds")
        return True

    def set_move_torque_current_avg(
        self,
        moveType: str = "relative",
        targetPosition: int = 5000,
        maxSpeed: int = 200,
        filterBelow: int = None,
        bypassError: bool = False,
        target: int | None = None,
    ):
        """
        Execute a move and compute the average torque‑forming current (Iq) observed during motion.

        Parameters:
        moveType (str): "relative" or "absolute".
        targetPosition (int): Position target used if `target` is None. Default 5000.
        maxSpeed (int): Maximum speed for the move. Default 200.
        filterBelow (int | None): If provided, discard |Iq| < threshold before averaging.
        bypassError (bool): If True, exit when timeout elapses without raising. Default False.
        target (int | None): Optional explicit target override; if provided, supersedes `targetPosition`.

        Returns:
        dict: {
            "average": int,     # Rounded mean of filtered Iq samples
            "data": list[int]   # Filtered Iq series (sign‑normalized for direction)
        }

        Raises:
        ValueError: If `moveType` is not "relative" or "absolute".
        RuntimeError: If the target is not reached before the internal timer elapses and `bypassError` is False.

        Notes:
        - Collects Iq from OD 0x2039:0x02 during the polling loop.
        - Direction sign is normalized so positive motion yields positive samples.
        - `_filter_data()` performs optional threshold filtering prior to averaging.
        """
        if target is None:
            target = targetPosition

        currentData = []
        invert = True if (target > 0) else False

        if moveType.lower() not in ["relative", "absolute"]:
            raise ValueError("moveType must be 'relative' or 'absolute'")
        if moveType.lower() == "relative":
            self._set_relative_move(targetPosition=int(target), maxSpeed=int(maxSpeed))
        else:
            self._set_absolute_move(targetPosition=int(target), maxSpeed=int(maxSpeed))

        self._start_timer()
        time.sleep(0.01)

        while not self._get_target_reached():
            iq = self.get_torque_forming_current()
            currentData.append((-iq) if invert else iq)
            if bypassError and self._timer_is_done():
                break
            if not bypassError and self._timer_is_done():
                raise RuntimeError(f"Motor failed to reach position in {self.timerDuration} seconds")

        filtered = self._filter_data(below=True, threshold=filterBelow, dataSet=currentData)
        avg = 0 if len(filtered) == 0 else round(sum(filtered) / len(filtered))
        return {"average": avg, "data": filtered}

    def set_failure_timer_duration(self, duration: float):
        """
        Set the movement timeout duration used by `_timer_is_done()`.

        Parameters:
        duration (float): Timeout in seconds.

        Returns:
        None

        Notes:
        - Applies to operations like `set_move()` and `set_move_torque_current_avg()`.
        """
        self.timerDuration = duration

    def set_pole_pair(self, stepAngle: float = 1.8):
        """
        Configure pole‑pair count based on motor step angle.

        Parameters:
        stepAngle (float): Step angle in degrees. Supported: 1.8 or 0.9.

        Returns:
        bool: True if the pole‑pair setting was written.

        Raises:
        ValueError: If an unsupported step angle is provided.

        Notes:
        - Writes OD 0x2030:0x00 (UNSIGNED32). For 1.8° set 50; for 0.9° set 100.
        """
        if stepAngle in [1.8]:
            return self.set_od(index=0x2030, subindex=0x00, value=50)
        elif stepAngle in [0.9]:
            return self.set_od(index=0x2030, subindex=0x00, value=100)
        else:
            raise ValueError("Unsupported stepAngle; expected 1.8 or 0.9")

    def set_open_loop(self):
        """
        Switch the controller to open‑loop mode.

        Returns:
        bool: True when the mode bit is written.

        Notes:
        - Reads OD 0x3202:0x00 (UNSIGNED32), clears bit 0 in the LSB, and writes the value back.
        - Uses `_modify_bit()` to update the byte, preserving other bits.
        """
        status = self.get_od(index=0x3202, subindex=0x00, valueType="unsigned32", decode=False)
        status[0] = self._modify_bit(bitIndex=0, value=0, byteToModify=status[0])
        value = int.from_bytes(bytes(status), byteorder="little", signed=False)
        self.set_od(index=0x3202, subindex=0x00, value=value, valueType="unsigned32")
        return True

    def set_closed_loop(self):
        """
        Switch the controller to closed‑loop mode.

        Returns:
        bool: True when the mode bit is written.

        Notes:
        - Reads OD 0x3202:0x00 (UNSIGNED32), sets bit 0 in the LSB, and writes the value back.
        - Uses `_modify_bit()` to update the byte, preserving other bits.
        """
        status = self.get_od(index=0x3202, subindex=0x00, valueType="unsigned32", decode=False)
        status[0] = self._modify_bit(bitIndex=0, value=1, byteToModify=status[0])
        value = int.from_bytes(bytes(status), byteorder="little", signed=False)
        self.set_od(index=0x3202, subindex=0x00, value=value, valueType="unsigned32")
        return True

    def set_enable_current_reduction(self, idleTimeout: int = 500, currReductionPercent: int = 90):
        """
        Enable idle current reduction in open‑loop mode.

        Parameters:
        idleTimeout (int): Idle time before reduction engages, in milliseconds. Default 500.
        currReductionPercent (int): Percent of rated current to hold when idle (0..100). Default 90.

        Returns:
        bool: True when the configuration is written.

        Notes:
        - Writes OD 0x3219:0x02 (UNSIGNED32 ms) and 0x3219:0x03 (UNSIGNED32 in tenth‑percent, 0..1000).
        - The percent is clamped into [0, 1000] after conversion to tenth‑percent.
        """
        self.set_od(0x3219, 0x02, max(0, int(idleTimeout)), "unsigned32")
        tenthPercent = max(0, min(1000, int(round(currReductionPercent * 10))))
        self.set_od(0x3219, 0x03, tenthPercent, "unsigned32")
        return True

    def set_disable_current_reduction(self):
        """
        Disable idle current reduction.

        Returns:
        bool: True when the configuration is written.

        Notes:
        - Writes OD 0x3219:0x02 = 0 ms and 0x3219:0x03 = 1000 (i.e., 100% of rated current).
        """
        self.set_od(0x3219, 0x02, 0, "unsigned32")
        self.set_od(0x3219, 0x03, 1000, "unsigned32")
        return True

    def set_auto_setup(self, pollInterval: float = 0.2, timeout: float = 120.0):
        """
        Run the N6 auto‑setup routine (commutation/phasing) in closed‑loop.

        Parameters:
        pollInterval (float): Polling interval (seconds) while waiting for completion. Default 0.2 s.
        timeout (float): Maximum time to wait for completion (seconds). Default 120 s.

        Returns:
        bool: True when auto‑setup completes successfully.

        Raises:
        TimeoutError: If the routine does not complete within `timeout`.

        Notes:
        - Ensures closed‑loop mode, sets OD 0x6060:0x00 = -2 (FEh), transitions 6040h to "Operation Enabled",
          then latches/unlatches bit 4 to start auto‑setup.
        - Waits until OD 0x6041:0x00 bit 12 indicates completion (`_get_auto_setup_complete()`).
        """
        self.set_closed_loop()
        self.set_od(index=0x6060, subindex=0x00, value=-2, valueType="integer8")

        self.set_od(0x6040, 0x00, 0x0006, "unsigned16")
        time.sleep(0.05)
        self.set_od(0x6040, 0x00, 0x0007, "unsigned16")
        time.sleep(0.05)
        self.set_od(0x6040, 0x00, 0x000F, "unsigned16")
        time.sleep(0.05)

        cw = self.get_od(0x6040, 0x00, "unsigned16", True)
        cwStart = cw | (1 << 4)
        self.set_od(0x6040, 0x00, cwStart, "unsigned16")
        time.sleep(0.05)
        self.set_od(0x6040, 0x00, (cwStart & ~(1 << 4)), "unsigned16")

        start = time.perf_counter()
        while True:
            if self._get_auto_setup_complete():
                return True
            if (time.perf_counter() - start) > timeout:
                raise TimeoutError("Auto-setup did not complete before timeout")
            time.sleep(pollInterval)

    def set_analog_config(
        self,
        analog1Offset: int,
        analog2Offset: int,
        analog1Numerator: int,
        analog2Numerator: int,
        analog1Denominator: int,
        analog2Denominator: int,
    ):
        """
        Configure analog input scaling for both channels.

        Parameters:
        analog1Offset (int): A1 offset (OD 0x3321:0x01, INTEGER16).
        analog2Offset (int): A2 offset (OD 0x3321:0x02, INTEGER16).
        analog1Numerator (int): A1 numerator (OD 0x3322:0x01, INTEGER16).
        analog2Numerator (int): A2 numerator (OD 0x3322:0x02, INTEGER16).
        analog1Denominator (int): A1 denominator (OD 0x3323:0x01, INTEGER16).
        analog2Denominator (int): A2 denominator (OD 0x3323:0x02, INTEGER16).

        Returns:
        bool: True when all scaling terms are written.

        Notes:
        - Together define y = (raw + offset) * numerator / denominator.
        """
        self.set_od(0x3321, 0x01, analog1Offset, "integer16")
        self.set_od(0x3321, 0x02, analog2Offset, "integer16")
        self.set_od(0x3322, 0x01, analog1Numerator, "integer16")
        self.set_od(0x3322, 0x02, analog2Numerator, "integer16")
        self.set_od(0x3323, 0x01, analog1Denominator, "integer16")
        self.set_od(0x3323, 0x02, analog2Denominator, "integer16")
        return True

    def set_analog_1_mode(self, mode: str = "voltage"):
        """
        Validate Analog Input 1 mode.

        Parameters:
        mode (str): Expected "voltage". Other modes are not supported via this interface.

        Returns:
        bool: True if the mode is acceptable.

        Raises:
        NotImplementedError: If a non‑"voltage" mode is requested.

        Notes:
        - The N6 interface exposes scaling, not mode switching; use `set_analog_config()` instead.
        """
        if mode.lower() != "voltage":
            raise NotImplementedError("N6 analog mode switching is not exposed; use scaling (3321/3322/3323).")
        return True

    def set_analog_2_mode(self, mode: str = "voltage"):
        """
        Validate Analog Input 2 mode.

        Parameters:
        mode (str): Expected "voltage". Other modes are not supported via this interface.

        Returns:
        bool: True if the mode is acceptable.

        Raises:
        NotImplementedError: If a non‑"voltage" mode is requested.

        Notes:
        - The N6 interface exposes scaling, not mode switching; use `set_analog_config()` instead.
        """
        if mode.lower() != "voltage":
            raise NotImplementedError("N6 analog mode switching is not exposed; use scaling (3321/3322/3323).")
        return True

    def set_digital_output(self, outputIndex: int, outputValue: int):
        """
        Set the state of a digital output line.

        Parameters:
        outputIndex (int): 1‑based output index (OUT1=1, ...).
        outputValue (int): Desired state, 0 or 1.

        Returns:
        None

        Raises:
        ValueError: If `outputIndex` < 1 or `outputValue` not in {0,1}.

        Notes:
        - Reads OD 0x60FE:0x01 (UNSIGNED32), modifies the appropriate bit (OUT1.. at bit 16+),
          and writes the updated value back.
        - Uses integer packing/unpacking to preserve other output states.
        """
        if outputIndex < 1:
            raise ValueError("outputIndex must be >= 1")
        if outputValue not in (0, 1):
            raise ValueError("outputValue must be 0 or 1")
        rawBytes = self.get_od(index=0x60FE, subindex=0x01, valueType="unsigned32", decode=False)
        rawVal = int.from_bytes(bytes(rawBytes), byteorder="little", signed=False)
        bitPos = 16 + (outputIndex - 1)
        rawVal = (rawVal | (1 << bitPos)) if outputValue == 1 else (rawVal & ~(1 << bitPos))
        self.set_od(index=0x60FE, subindex=0x01, value=rawVal, valueType="unsigned32")

    def set_max_voltage(self, voltageValue: int = 4000):
        """
        Set the maximum motor voltage limit.

        Parameters:
        voltageValue (int): Millivolt limit to store in OD 0x321E:0x00 (UNSIGNED32). Default 4000.

        Returns:
        None

        Notes:
        - Ensure the value aligns with motor ratings to avoid over‑voltage conditions.
        """
        self.set_od(index=0x321E, subindex=0x00, value=int(voltageValue), valueType="unsigned32")

    def set_rated_current(self, currentValue: int = 1800):
        """
        Set the rated motor current (converted to drive format).

        Parameters:
        currentValue (int): Absolute current in milliamps to target.

        Returns:
        None

        Raises:
        ValueError: If `currentValue` < 0.

        Notes:
        None
        """
        self.set_od(index=0x6075, subindex=0x00, value=currentValue, valueType="unsigned32")

    def set_max_current(self, currentValue: int = 1000):
        """
        Set the maximum motor current limit (converted to drive format).

        Parameters:
        currentValue (int): Absolute current in milliamps to target.

        Returns:
        None

        Raises:
        ValueError: If `currentValue` < 0.

        Notes:
        None
        """
        self.set_od(index=0x6073, subindex=0x00, value=currentValue, valueType="unsigned32")

    def set_acceleration(self, accelValue: int = 2000):
        """
        Set the motion profile acceleration.

        Parameters:
        accelValue (int): Acceleration to store at OD 0x6083:0x00 (UNSIGNED32).

        Returns:
        None
        """
        self.set_od(index=0x6083, subindex=0x00, value=int(accelValue), valueType="unsigned32")

    def set_deceleration(self, decelValue: int = 2000):
        """
        Set the motion profile deceleration.

        Parameters:
        decelValue (int): Deceleration to store at OD 0x6084:0x00 (UNSIGNED32).

        Returns:
        None
        """
        self.set_od(index=0x6084, subindex=0x00, value=int(decelValue), valueType="unsigned32")

    def set_motion_profile_type(self, trapezoidal: bool = True, jerkLimitedRamp: bool = False):
        if (trapezoidal and jerkLimitedRamp) or (not trapezoidal and not jerkLimitedRamp):
            raise ValueError("trapezoidal and jerkLimitedRamp cannot both be True or False, simoultaneously.")
        elif trapezoidal and not jerkLimitedRamp:
            self.set_od(index=0x6086, subindex=0x00, value=0, valueType="unsigned32")
        elif jerkLimitedRamp and not trapezoidal:
            self.set_od(index=0x6086, subindex=0x00, value=3, valueType="unsigned32")

    def set_motor_resistance(self, resistanceOhms: int = 2):
        """
        Set the motor resistance (converted to drive format).

        Parameters:
        resistanceOhms (int): Resistance in ohms to target.

        Returns:
        None
        """
        self.set_od(index=0x3380, subindex=0x01, value=resistanceOhms, valueType="unsigned32")

    def set_motor_inductance(self, inductanceH: int = 4000):
        """
        Set the motor inductance (converted to drive format).

        Parameters:
        inductanceH (int): Inductance in henries to target.

        Returns:
        None
        """
        self.set_od(index=0x3380, subindex=0x02, value=inductanceH, valueType="unsigned32")

    def set_motor_magnetic_flux(self, fluxMuVs: int = 1500000):
        """
        Set the motor magnetic flux (converted to drive format).

        Parameters:
        fluxMuVs (int): Magnetic flux in microvolt-seconds to target.

        Returns:
        None
        """
        self.set_od(index=0x3380, subindex=0x03, value=fluxMuVs, valueType="unsigned32")

    def set_homing_on_current_position(self, currentPosition: int = 0):
        """
        Set the current position as the homing reference (zero).

        Parameters:
        currentPosition (int): Current position to store as home (OD 0x30B2:0x01, INTEGER32). Default 0.

        Returns:
        None
        """
        self.set_od(index=0x2291, subindex=0x01, value=int(currentPosition), valueType="integer32")
        cmd = self._pdiCmd["HomeCurrentPos"] + (128 if self.toggle else 0)
        self.set_od(0x2291, 0x04, cmd, "command")
        self.toggle = not self.toggle

        self._start_timer()
        # controller registration delay
        time.sleep(0.1)

        while not self._get_homing_done():
            time.sleep(0.1)
            if self._timer_is_done():
                raise RuntimeError(f"Motor failed to complete homing in {self.timerDuration} seconds")
        return True

    #####################
    ## Public Getters ###
    #####################
    def keep_alive(self) -> int:
        """
        Read the statusword object, as a means to keep a connection alive

        Returns:
        None
        """
        return self.get_od(index=0x6041, subindex=0x00, valueType="integer16")

    ###########################
    ## Private Class Methods ##
    ###########################

    #####################
    ## Private Getters ##
    #####################

    def _get_auto_setup_complete(self) -> bool:
        """
        Internal: Check the CiA‑402 statusword for auto‑setup completion.

        Returns:
        bool: True if OD 0x6041:0x00 bit 12 is set.

        Notes:
        - Bit 12 of the statusword indicates "auto setup complete" per this implementation.
        """
        statusWord = self.get_od(0x6041, 0x00, "unsigned16", True)
        return bool((statusWord >> 12) & 0x1)

    def _get_target_reached(self) -> bool:
        """
        Internal: Check the CiA‑402 statusword for target reached.

        Returns:
        bool: True if OD 0x6041:0x00 bit 10 is set.
        """
        statusWord = self.get_od(index=0x2292, subindex=0x01, valueType="INTEGER16", decode=False)
        firstByte = statusWord[0]  # Entries 0-7
        return self._check_bit(firstByte, self._pdiStatusMapping["PdiStatusTargetReached"], 1)

    def _get_homing_done(self) -> bool:
        """
        Internal: Check the CiA‑402 statusword for target reached.

        Returns:
        bool: True if OD 0x6041:0x00 bit 10 is set.
        """
        statusWord = self.get_od(index=0x2292, subindex=0x01, valueType="INTEGER16", decode=False)
        secondByte = statusWord[1]  # Entries 8-15
        return self._check_bit(secondByte, self._pdiStatusMapping["PdiStatusHomingDone"], 1)

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

    def _modify_bit(self, bitIndex: int, value: int, byteToModify: int) -> int:
        """
        Internal: Return a copy of `byteToModify` with one bit changed.

        Parameters:
        bitIndex (int): Bit position 0..7.
        value (int): New bit value (0 or 1).
        byteToModify (int): Original 8‑bit value.

        Returns:
        int: Updated 8‑bit value with the requested bit toggled.
        """
        mask = 1 << bitIndex
        return (byteToModify | mask) if value else (byteToModify & ~mask)

    def _check_bit(self, decimalValue: int, bitPosition: int, expectedValue: int) -> bool:
        """
        Internal: Test whether a given bit equals `expectedValue`.

        Parameters:
        decimalValue (int): Source integer.
        bitPosition (int): Bit position to test (0..31).
        expectedValue (int): Expected bit value, 0 or 1.

        Returns:
        bool: True if the bit equals `expectedValue`; False otherwise.
        """
        return 1 if ((decimalValue >> bitPosition) & 0x1) == expectedValue else 0

    def _start_timer(self):
        """
        Internal: Start (or restart) the movement timeout timer.

        Returns:
        None

        Notes:
        - Uses a high‑resolution counter for accurate elapsed time measurement.
        """
        self.failureTimer = time.perf_counter()

    def _timer_is_done(self):
        """
        Internal: Check whether the movement timeout has elapsed.

        Returns:
        bool: True if (now - start) >= `self.timerDuration`, else False.
        """
        return (time.perf_counter() - self.failureTimer) >= self.timerDuration

    def _filter_data(self, below: bool = True, threshold: int = None, dataSet: list = []):
        """
        Internal: Filter a numeric series against a magnitude threshold.

        Parameters:
        below (bool): If True, keep values with |v| >= threshold (filter out below).
                      If False, keep values with |v| <= threshold (filter out above).
        threshold (int | None): Threshold magnitude. If None, return `dataSet` unchanged.
        dataSet (list[int]): Source data.

        Returns:
        list[int]: Filtered data list.

        Notes:
        - Used by `set_move_torque_current_avg()` to remove low‑level noise.
        """
        if threshold is None:
            return dataSet
        if below:
            return [v for v in dataSet if abs(v) >= threshold]
        else:
            return [v for v in dataSet if abs(v) <= threshold]

    ############################
    ## N6-specific motion impl ##
    ############################

    def _set_relative_move(self, targetPosition: int = 5000, maxSpeed: int = 200):
        """
        Internal: Issue a PDI "Profile Position Relative" command.

        Parameters:
        targetPosition (int): Relative target (OD 0x2291:0x01, INTEGER32).
        maxSpeed (int): Max speed (OD 0x2291:0x02, INTEGER16).

        Returns:
        None

        Notes:
        - Toggles OD 0x2291:0x04 with `ProfilePosRel` (+128 every other call) to ensure edge detection.
        """
        self.set_od(0x2291, 0x01, int(targetPosition), "integer32")
        self.set_od(0x2291, 0x02, int(maxSpeed), "integer16")
        cmd = self._pdiCmd["ProfilePosRel"] + (128 if self.toggle else 0)
        self.set_od(0x2291, 0x04, cmd, "command")
        self.toggle = not self.toggle

    def _set_absolute_move(self, targetPosition: int = 5000, maxSpeed: int = 200):
        """
        Internal: Issue a PDI "Profile Position Absolute" command.

        Parameters:
        targetPosition (int): Absolute target (OD 0x2291:0x01, INTEGER32).
        maxSpeed (int): Max speed (OD 0x2291:0x02, INTEGER16).

        Returns:
        None

        Notes:
        - Toggles OD 0x2291:0x04 with `ProfilePosAbs` (+128 every other call) to ensure edge detection.
        """
        self.set_od(0x2291, 0x01, int(targetPosition), "integer32")
        self.set_od(0x2291, 0x02, int(maxSpeed), "integer16")
        cmd = self._pdiCmd["ProfilePosAbs"] + (128 if self.toggle else 0)
        self.set_od(0x2291, 0x04, cmd, "command")
        self.toggle = not self.toggle

    def _set_velocity_move(self, targetVelocity: int = 0, maxSpeed: int = 0):
        """
        Internal: Issue a PDI "Profile Velocity" command.

        Parameters:
        targetVelocity (int): Velocity target (OD 0x2291:0x01, INTEGER32).
        maxSpeed (int): Currently accepted but not written by this helper (reserved).

        Returns:
        None

        Notes:
        - Toggles OD 0x2291:0x04 with `ProfileVelocity` (+128 every other call) to ensure edge detection.
        - If needed, write additional limits prior to calling this helper.
        """
        self.set_od(0x2291, 0x01, int(targetVelocity), "integer32")
        cmd = self._pdiCmd["ProfileVelocity"] + (128 if self.toggle else 0)
        self.set_od(0x2291, 0x04, cmd, "command")
        self.toggle = not self.toggle

    #############################
    ## EtherNet/IP / RR helpers ##
    #############################

    def _build_rr_data_packet(self, service: int, path: bytes, data: bytes) -> bytes:
        """
        Internal: Construct a CIP RRData payload.

        Parameters:
        service (int): CIP service code (e.g., 0x32 for GetOD, 0x33 for SetOD).
        path (bytes): Encoded CIP path (even number of bytes). For OD access: b'\\x20\\x64\\x24\\x01'.
        data (bytes): Service payload data (e.g., index/subindex/value bytes).

        Returns:
        bytes: RRData = [Service][Path Size in words][Path...][Data...]

        Raises:
        ValueError: If `path` length is not even.

        Notes:
        - Used by `get_od()` and `set_od()` prior to EtherNet/IP encapsulation.
        """
        if len(path) % 2 != 0:
            raise ValueError("CIP path must have an even number of bytes")
        rr = bytearray()
        rr.append(service & 0xFF)
        rr.append(len(path) // 2)  # path size in 16-bit words
        rr.extend(path)
        rr.extend(data)
        return bytes(rr)

    def _build_encapsulation(self, rrData: bytes) -> bytes:
        """
        Internal: Wrap a CIP RRData payload in an EtherNet/IP SendRRData (0x6F) encapsulation.

        Parameters:
        rrData (bytes): Prepared CIP RRData segment.

        Returns:
        bytes: Full EIP frame including encapsulation header, interface handle/timeout,
               and CPF with a Null Address item and Unconnected Data item.

        Raises:
        RuntimeError: If no EIP client is available.

        Notes:
        - Uses the active `session_handle` stored by the EIP client.
        - Item 1: Null Address (0x0000, len 0); Item 2: Unconnected Data (0x00B2, rrData length).
        """
        if self.eipClient is None:
            raise RuntimeError("EIP client not available")

        # Command Specific Data for SendRRData
        # Interface Handle = 0 (CIP), Timeout = 0
        # Item Count = 2
        #  Item 1: Null Address (0x0000, len 0)
        #  Item 2: Unconnected Data (0x00B2, len = len(rrData))
        csd = bytearray()
        csd.extend((0).to_bytes(4, "little"))  # Interface Handle
        csd.extend((0).to_bytes(2, "little"))  # Timeout
        csd.extend((2).to_bytes(2, "little"))  # Item Count
        csd.extend((0x0000).to_bytes(2, "little"))  # Null Address type
        csd.extend((0).to_bytes(2, "little"))  # Length = 0
        csd.extend((0x00B2).to_bytes(2, "little"))  # Unconnected Data item
        csd.extend((len(rrData)).to_bytes(2, "little"))
        csd.extend(rrData)

        # Encapsulation header (24 bytes)
        command = 0x6F  # SendRRData
        length = len(csd)
        header = bytearray()
        header.extend((command).to_bytes(2, "little"))
        header.extend((length).to_bytes(2, "little"))
        header.extend((self.eipClient.connnection_config.session_handle).to_bytes(4, "little"))
        header.extend((0).to_bytes(4, "little"))  # Status
        header.extend((0).to_bytes(8, "little"))  # Sender Context
        header.extend((0).to_bytes(4, "little"))  # Options

        return bytes(header + csd)

    def _recv_eip_response(self, minBytes: int = 44, timeout: float = 2.0) -> bytes:
        """
        Internal: Receive an EtherNet/IP response frame from the TCP socket.

        Parameters:
        minBytes (int): Minimum expected total bytes (including encapsulation header). Default 44.
        timeout (float): Socket timeout (seconds). Default 2.0.

        Returns:
        bytes: The concatenated EtherNet/IP response (header + payload), or partial if the socket closes.

        Notes:
        - Reads the 24‑byte encapsulation header first to get the size, then reads the remaining bytes.
        - Caller is responsible for validity checks (e.g., General Status byte at offset 42).
        """
        sock = self.eipClient.tcp_client_socket
        sock.settimeout(timeout)
        chunks = []
        # Read the fixed header first (24 bytes) to get the length
        header = sock.recv(24)
        if len(header) < 24:
            return header
        chunks.append(header)
        # Length field at bytes 2..3 (uint16 little-endian)
        length = int.from_bytes(header[2:4], "little")
        remaining = length
        while remaining > 0:
            part = sock.recv(remaining)
            if not part:
                break
            chunks.append(part)
            remaining -= len(part)
        return b"".join(chunks)

    def _decode_value(self, payload: bytes, valueType: str):
        """
        Internal: Decode a little‑endian payload into an integer of the requested type.

        Parameters:
        payload (bytes): Raw bytes beginning at the CIP service response data.
        valueType (str): One of "UNSIGNED32", "UNSIGNED16", "UNSIGNED8",
                         "INTEGER32", "INTEGER16", "INTEGER8".

        Returns:
        int: Decoded integer.

        Raises:
        ValueError: If `valueType` is unsupported.

        Notes:
        - Truncates to the appropriate width (4/2/1 bytes) before conversion.
        """
        valueType = valueType.upper()
        if valueType == "UNSIGNED32":
            return int.from_bytes(payload[:4], "little", signed=False)
        if valueType == "INTEGER32":
            return int.from_bytes(payload[:4], "little", signed=True)
        if valueType == "UNSIGNED16":
            return int.from_bytes(payload[:2], "little", signed=False)
        if valueType == "INTEGER16":
            return int.from_bytes(payload[:2], "little", signed=True)
        if valueType == "UNSIGNED8":
            return int.from_bytes(payload[:1], "little", signed=False)
        if valueType == "INTEGER8":
            return int.from_bytes(payload[:1], "little", signed=True)
        raise ValueError(f"Unsupported valueType: {valueType}")

    def _coerce_from_int(self, rawVal: int, valueType: str) -> int:
        """
        Internal: Coerce a Python int into the representable range/width for the given `valueType`.

        Parameters:
        rawVal (int): Source integer.
        valueType (str): One of "UNSIGNED32", "UNSIGNED16", "UNSIGNED8",
                         "INTEGER32", "INTEGER16", "INTEGER8".

        Returns:
        int: Value masked/clamped to the destination bit width and signedness.

        Raises:
        ValueError: If `valueType` is unsupported.

        Notes:
        - Intended for pre‑encoding validation or masking prior to OD writes.
        - Keeps interface‑level behavior consistent regardless of Python int size.
        """
        valueType = valueType.upper()
        if valueType in ("UNSIGNED32", "INTEGER32"):
            width = 32
        elif valueType in ("UNSIGNED16", "INTEGER16"):
            width = 16
        elif valueType in ("UNSIGNED8", "INTEGER8"):
            width = 8
        else:
            return rawVal

        if valueType.startswith("UNSIGNED"):
            return rawVal & ((1 << width) - 1)

        # two's complement sign for INTEGER*
        signBit = 1 << (width - 1)
        mask = (1 << width) - 1
        v = rawVal & mask
        return v - (1 << width) if (v & signBit) else v
