Private/Stepper.Backend.ps1

#Requires -Version 5.1

# ---------------------------------------------------------------------------
# Stepper.Backend.ps1
# Platform-agnostic stepper motor backend for PsGadget.
#
# Supports FT232R and FT232H via async bit-bang mode (ADBUS0-3).
# Reduces jitter by pre-computing the full step sequence as a byte buffer
# and issuing a single bulk USB write, letting the chip's built-in baud-rate
# timer pace each coil state at the requested step rate.
#
# Coil byte layout (lower nibble, IN1-IN4 via ULN2003 driver board):
# bit 0 = IN1 bit 1 = IN2 bit 2 = IN3 bit 3 = IN4
#
# 28BYJ-48 calibration note:
# Empirical measurements: ~508-509 output steps/rev.
# Gear-ratio derivation: 4075.7728 / 8 = ~509.47 half-step blocks/rev
# Source: http://www.jangeox.be/2013/10/stepper-motor-28byj-48_25.html
# This value is NOT exactly 4096. Do NOT hardcode 2048/4096.
# Use Get-PsGadgetStepperDefaultStepsPerRev for mode-appropriate defaults,
# or pass a -StepsPerRevolution override when the motor has been calibrated.
# ---------------------------------------------------------------------------

# Module-level calibration constant. Expose via Get-PsGadgetStepperDefaultStepsPerRev.
$script:Stepper_HalfStepsPerRev_28BYJ48 = 4075.7728395061727

# ---------------------------------------------------------------------------
# Get-PsGadgetStepperDefaultStepsPerRev
# Returns the calibrated default steps-per-revolution for the specified step
# mode. For 28BYJ-48:
# Half: ~4075.77 individual half-step pulses per output-shaft revolution
# Full: ~2037.89 (half of the above; each full-step moves twice as far)
#
# Pass your measured value via -StepsPerRevolution to Invoke-PsGadgetStepper
# or set $dev.StepsPerRevolution on the PsGadgetFtdi object to override.
# ---------------------------------------------------------------------------
function Get-PsGadgetStepperDefaultStepsPerRev {
    [CmdletBinding()]
    [OutputType([double])]
    param(
        [ValidateSet('Full', 'Half')]
        [string]$StepMode = 'Half'
    )

    if ($StepMode -eq 'Full') {
        return ($script:Stepper_HalfStepsPerRev_28BYJ48 / 2.0)
    }
    return $script:Stepper_HalfStepsPerRev_28BYJ48
}

# ---------------------------------------------------------------------------
# Get-PsGadgetStepSequence
# Returns the ordered phase byte sequence for the given step mode and
# direction. Each byte encodes the coil state for one step position.
#
# PinOffset shifts all bytes left by N bits to accommodate motors wired to
# higher ADBUS pins (e.g. PinOffset=4 maps IN1-IN4 to bits 4-7).
# ---------------------------------------------------------------------------
function Get-PsGadgetStepSequence {
    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [ValidateSet('Full', 'Half')]
        [string]$StepMode = 'Half',

        [ValidateSet('Forward', 'Reverse')]
        [string]$Direction = 'Forward',

        [ValidateRange(0, 4)]
        [byte]$PinOffset = 0
    )

    # Half-step 8-phase (smoother, higher resolution, default for 28BYJ-48)
    # Drives single coils then adjacent coil pairs in rotation.
    # Phase order: IN4, IN4+IN3, IN3, IN3+IN2, IN2, IN2+IN1, IN1, IN1+IN4
    $halfStep = [byte[]]@(0x08, 0x0C, 0x04, 0x06, 0x02, 0x03, 0x01, 0x09)

    # Full-step 4-phase (two coils energised simultaneously - higher torque)
    # Phase order: IN1+IN3, IN2+IN3, IN2+IN4, IN1+IN4
    $fullStep = [byte[]]@(0x05, 0x06, 0x0A, 0x09)

    [byte[]]$seq = if ($StepMode -eq 'Half') { $halfStep } else { $fullStep }

    if ($Direction -eq 'Reverse') {
        [System.Array]::Reverse($seq)
    }

    if ($PinOffset -gt 0) {
        $shifted = [byte[]]::new($seq.Length)
        for ($i = 0; $i -lt $seq.Length; $i++) {
            $shifted[$i] = [byte](($seq[$i] -shl $PinOffset) -band 0xFF)
        }
        return $shifted
    }

    return $seq
}

# ---------------------------------------------------------------------------
# Invoke-PsGadgetStepperMove
# Core step dispatch. Called by Invoke-PsGadgetStepper and
# PsGadgetFtdi.Step() / PsGadgetFtdi.StepDegrees().
#
# Jitter-reduction strategy:
# 1. Build the complete step sequence as a contiguous byte[] ($Steps entries)
# 2. Configure FT232R/FT232H async bit-bang mode (ADBUS0-3)
# 3. Set baud rate = 16000 / DelayMs so the baud-rate timer paces each
# pin-state transition at the requested interval
# 4. Issue a single FT_Write() call with the full buffer
# 5. De-energize coils (write 0x00) to prevent heat buildup at rest
#
# On stub/no-hardware machines the write is logged but not executed.
# ---------------------------------------------------------------------------
function Invoke-PsGadgetStepperMove {
    [CmdletBinding(SupportsShouldProcess = $false)]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Ftdi,

        [Parameter(Mandatory = $true)]
        [ValidateRange(1, [int]::MaxValue)]
        [int]$Steps,

        [ValidateSet('Forward', 'Reverse')]
        [string]$Direction = 'Forward',

        [ValidateSet('Full', 'Half')]
        [string]$StepMode = 'Half',

        # Inter-step delay in milliseconds.
        # Translates to baud rate: baud = 16000 / DelayMs
        # Minimum recommended for 28BYJ-48: 1ms (may stall at higher speeds)
        # Safe default: 2ms
        [ValidateRange(1, 1000)]
        [int]$DelayMs = 2,

        # Output direction mask. Bits correspond to the target GPIO bank pins.
        # Default 0x0F = pins 0-3 all outputs (IN1-IN4 via ULN2003).
        [byte]$PinMask = 0x0F,

        # Shift IN1-IN4 bits left by this many positions.
        # Use when motor is wired on upper ADBUS pins (D4-D7).
        [ValidateRange(0, 4)]
        [byte]$PinOffset = 0,

        # Use ACBUS (C-bank) instead of ADBUS (D-bank) for the MPSSE step writes.
        # ADBUS (default): SET_BITS_LOW (0x80) -- pins D0-D7
        # ACBUS: SET_BITS_HIGH (0x82) -- pins C0-C7 (FT232H only)
        # Required when stepper is wired to ACBUS C0-C3 alongside I2C on ADBUS D0/D1.
        [switch]$AcBus
    )

    $log = $Ftdi.Logger
    $bankLabel = if ($AcBus) { 'ACBUS' } else { 'ADBUS' }
    $log.WriteDebug("StepperMove: $Steps $StepMode steps / $Direction / delay=${DelayMs}ms / mask=0x$($PinMask.ToString('X2')) / offset=$PinOffset / bank=$bankLabel")

    # --- build phase sequence ---
    $seq    = Get-PsGadgetStepSequence -StepMode $StepMode -Direction $Direction -PinOffset $PinOffset
    $seqLen = $seq.Length

    $buf = [byte[]]::new($Steps)
    for ($i = 0; $i -lt $Steps; $i++) {
        $buf[$i] = [byte]($seq[$i % $seqLen] -band $PinMask)
    }

    $log.WriteTrace("StepperMove: phase buffer $Steps bytes built")
    $script:PsGadgetLogger.WriteProto('STEPPER',
            "MOVE $Steps steps $StepMode $Direction ${DelayMs}ms/step bank=$bankLabel")

    $conn      = $Ftdi._connection
    $gpioMethod = if ($conn -and $conn.PSObject.Properties['GpioMethod']) { $conn.GpioMethod } else { '' }

    # --- write per-step loop ---
    # Three hardware paths:
    #
    # MPSSE (FT232H, Windows/Linux with libftd2xx): three-byte MPSSE command per step.
    # ADBUS (default, -AcBus not set): SET_BITS_LOW (0x80, value, direction)
    # D0-D3 reserved for MPSSE I2C; use D4-D7 with PinOffset=4 / PinMask=0xF0.
    # ACBUS (-AcBus switch): SET_BITS_HIGH (0x82, value, direction)
    # C0-C7 independent of ADBUS; use when stepper is on C0-C3 alongside I2C.
    # Both keep the device in MPSSE mode; I2C (SSD1306) continues to work.
    # Reference: FTDI AN_108 section 3.6.1 (SET_DATA_BITS_LOW_BYTE / HIGH_BYTE).
    #
    # IoT (FT232H on macOS/Linux via dotnet IoT backend): uses GpioController.
    # Coils must be wired to ACBUS0-3 (C0-C3). ADBUS is the MPSSE protocol bus.
    # IoT GpioController maps ACBUS pin N to controller pin N+8.
    #
    # AsyncBitBang (FT232R or explicit override): 1-byte direct pin state write.
    # Requires prior SetBitMode(AsyncBitBang). Mode switch handled below.
    #
    # Both paths use a Stopwatch spin-wait instead of Start-Sleep.
    # Start-Sleep has a ~15ms minimum granularity on Windows; the spin-wait
    # achieves sub-millisecond accuracy at the cost of one CPU core spinning
    # for the duration of the move.

    if ($conn -and $conn.PSObject.Properties['Device'] -and $conn.Device) {

        $sw          = [System.Diagnostics.Stopwatch]::new()
        $targetTicks = [long]($DelayMs * ([System.Diagnostics.Stopwatch]::Frequency / 1000.0))

        if ($gpioMethod -eq 'MPSSE' -or $gpioMethod -eq 'MpsseI2c') {
            # MPSSE GPIO path. Command byte selects the GPIO bank:
            # 0x80 (SET_BITS_LOW) = ADBUS D0-D7 (default)
            # 0x82 (SET_BITS_HIGH) = ACBUS C0-C7 (-AcBus)
            # Direction byte = PinMask (bits in mask are outputs).
            # No mode switch; device remains MPSSE-capable for I2C/SSD1306 after call.
            $mpsseCmdByte = if ($AcBus) { [byte]0x82 } else { [byte]0x80 }
            $log.WriteInfo("StepperMove: MPSSE $bankLabel path, $Steps steps @ ${DelayMs}ms")
            $mpsseCmd = [byte[]]@($mpsseCmdByte, 0x00, $PinMask)
            try {
                for ($i = 0; $i -lt $Steps; $i++) {
                    $mpsseCmd[1] = $buf[$i]
                    [uint32]$written = 0
                    $sw.Restart()
                    $conn.Device.Write($mpsseCmd, 3, [ref]$written) | Out-Null
                    while ($sw.ElapsedTicks -lt $targetTicks) {}
                }
                $log.WriteInfo("StepperMove: completed $Steps steps (MPSSE/$bankLabel)")
                $script:PsGadgetLogger.WriteProto('STEPPER', "DONE $Steps steps MPSSE/$bankLabel")
            } catch [System.NotImplementedException] {
                $log.WriteTrace("StepperMove stub: FT_Write not implemented (no hardware)")
            } catch {
                $log.WriteError("StepperMove FT_Write error: $($_.Exception.Message)")
                throw
            }
            # De-energize: set all coil pins low, keep direction mask
            try {
                $mpsseCmd[1] = 0x00
                [uint32]$zw = 0
                $conn.Device.Write($mpsseCmd, 3, [ref]$zw) | Out-Null
                $log.WriteTrace("StepperMove: coils de-energized (MPSSE)")
            } catch {
                $log.WriteTrace("StepperMove de-energize stub: $($_.Exception.Message)")
            }

        } elseif ($gpioMethod -eq 'IoT') {
            # IoT GpioController path (FT232H on macOS/Linux via dotnet IoT backend).
            # Coils must be wired to ACBUS0-3 (C0-C3); IoT maps these to pins 8-11.
            $gpioCtrl = $conn.GpioController
            if (-not $gpioCtrl) { throw "IoT connection is missing GpioController" }
            $log.WriteInfo("StepperMove: IoT ACBUS path, $Steps steps @ ${DelayMs}ms")
            for ($p = 0; $p -le 3; $p++) {
                $iotPin = $p + 8
                if (-not $gpioCtrl.IsPinOpen($iotPin)) {
                    $gpioCtrl.OpenPin($iotPin, [System.Device.Gpio.PinMode]::Output)
                }
            }
            try {
                for ($i = 0; $i -lt $Steps; $i++) {
                    $stepByte = $buf[$i]
                    $sw.Restart()
                    for ($p = 0; $p -le 3; $p++) {
                        $pinVal = if ($stepByte -band (1 -shl $p)) {
                            [System.Device.Gpio.PinValue]::High
                        } else {
                            [System.Device.Gpio.PinValue]::Low
                        }
                        $gpioCtrl.Write($p + 8, $pinVal)
                    }
                    while ($sw.ElapsedTicks -lt $targetTicks) {}
                }
                $log.WriteInfo("StepperMove: completed $Steps steps (IoT/ACBUS)")
                $script:PsGadgetLogger.WriteProto('STEPPER', "DONE $Steps steps IoT/ACBUS")
            } catch {
                $log.WriteError("StepperMove IoT error: $($_.Exception.Message)")
                throw
            }
            # De-energize
            try {
                for ($p = 0; $p -le 3; $p++) { $gpioCtrl.Write($p + 8, [System.Device.Gpio.PinValue]::Low) }
                $log.WriteTrace("StepperMove: coils de-energized (IoT)")
            } catch {
                $log.WriteTrace("StepperMove de-energize stub: $($_.Exception.Message)")
            }

        } else {
            # AsyncBitBang path (FT232R or devices not in MPSSE mode).
            # Switch mode if needed; FT232H opened in MPSSE requires a reset first.
            $activeMode = if ($conn.PSObject.Properties['ActiveMode']) { $conn.ActiveMode } else { '' }
            if ($activeMode -ne 'AsyncBitBang') {
                $log.WriteInfo("StepperMove: switching to AsyncBitBang (was '$activeMode')")
                try {
                    $conn.Device.ResetDevice() | Out-Null
                    $log.WriteTrace("StepperMove: ResetDevice before mode switch")
                    Start-Sleep -Milliseconds 50
                    $conn.Device.Purge(3) | Out-Null
                    Start-Sleep -Milliseconds 10
                } catch {
                    $log.WriteTrace("StepperMove: ResetDevice/Purge not available: $($_.Exception.Message)")
                }
                Set-PsGadgetFtdiMode -PsGadget $Ftdi -Mode AsyncBitBang -Mask $PinMask | Out-Null
            }

            $log.WriteInfo("StepperMove: AsyncBitBang path, $Steps steps @ ${DelayMs}ms")
            $stepBuf = [byte[]]@(0x00)
            try {
                for ($i = 0; $i -lt $Steps; $i++) {
                    $stepBuf[0] = $buf[$i]
                    [uint32]$written = 0
                    $sw.Restart()
                    $conn.Device.Write($stepBuf, 1, [ref]$written) | Out-Null
                    while ($sw.ElapsedTicks -lt $targetTicks) {}
                }
                $log.WriteInfo("StepperMove: completed $Steps steps (AsyncBitBang)")
                $script:PsGadgetLogger.WriteProto('STEPPER', "DONE $Steps steps AsyncBitBang")
            } catch [System.NotImplementedException] {
                $log.WriteTrace("StepperMove stub: FT_Write not implemented (no hardware)")
            } catch {
                $log.WriteError("StepperMove FT_Write error: $($_.Exception.Message)")
                throw
            }
            try {
                $stepBuf[0] = 0x00
                [uint32]$zw = 0
                $conn.Device.Write($stepBuf, 1, [ref]$zw) | Out-Null
                $log.WriteTrace("StepperMove: coils de-energized (AsyncBitBang)")
            } catch {
                $log.WriteTrace("StepperMove de-energize stub: $($_.Exception.Message)")
            }
        }

    } else {
        # Stub mode: no device handle
        $log.WriteTrace("StepperMove stub: no Device handle (Steps=$Steps, Mode=$StepMode, Dir=$Direction)")
    }
}

# ---------------------------------------------------------------------------
# Invoke-PsGadgetStepDirMove
# Step/direction driver backend for TB6600 and similar dedicated stepper drivers.
#
# Unlike the coil-sequence path (Invoke-PsGadgetStepperMove), step/dir drivers
# handle all phase sequencing internally. The host only needs to:
# 1. Assert ENA+ to enable the driver
# 2. Set DIR+ for direction
# 3. Pulse PUL+ once per step (rising edge triggers the driver)
#
# Default pin wiring (matches TB6600 on FT232H ACBUS/CBUS):
# CBUS0 (C0) -> PUL+ rising edge per step
# CBUS1 (C1) -> DIR+ forward = HIGH
# ENA+/ENA- -> looped (always enabled; use -NoEnable to skip ENA control)
#
# If ENA+ is wired to a CBUS pin set -EnaPin and omit -NoEnable.
#
# TB6600 minimum timing (from datasheet):
# PUL+ high time: 2.5 µs minimum (default 5 µs gives safe margin)
# DIR setup time: 5 µs before first PUL rising edge
#
# Hardware paths supported:
# MPSSE (FT232H on Windows/Linux): SET_BITS_HIGH 0x82 on ACBUS
# IoT (FT232H on macOS/Linux via dotnet IoT): GpioController pins N+8
#
# AsyncBitBang (FT232R) is NOT supported for TB6600 — CBUS on FT232R uses a
# separate bit-bang mode (0x20) unrelated to ACBUS and is too slow for step/dir.
# ---------------------------------------------------------------------------
function Invoke-PsGadgetStepDirMove {
    [CmdletBinding(SupportsShouldProcess = $false)]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Ftdi,

        [Parameter(Mandatory = $true)]
        [ValidateRange(1, [int]::MaxValue)]
        [int]$Steps,

        [ValidateSet('Forward', 'Reverse')]
        [string]$Direction = 'Forward',

        # Inter-step delay from PUL falling edge to next PUL rising edge.
        [ValidateRange(1, 10000)]
        [int]$DelayMs = 2,

        # ACBUS pin numbers for the TB6600 signal lines.
        # Defaults match: CBUS0=PUL+, CBUS1=DIR+, ENA looped (-NoEnable).
        [ValidateRange(0, 7)]
        [byte]$PulPin = 0,

        [ValidateRange(0, 7)]
        [byte]$DirPin = 1,

        # When ENA+/ENA- are looped (always-enabled), set -NoEnable to skip ENA control.
        # When ENA+ is wired to a CBUS pin, omit -NoEnable and set -EnaPin accordingly.
        [switch]$NoEnable,

        [ValidateRange(0, 7)]
        [byte]$EnaPin = 2,

        # PUL+ high-pulse width in microseconds.
        # TB6600 minimum is 2.5 µs; 5 µs default gives a safe margin.
        [ValidateRange(1, 1000)]
        [int]$PulseWidthUs = 5,

        # Limit switch support. When -UseLimits is set, ACBUS pins LeftLimitPin
        # and RightLimitPin are sampled after each step pulse.
        # The move aborts immediately when the relevant limit for the current
        # direction is hit (Forward checks right; Reverse checks left).
        [switch]$UseLimits,

        [ValidateRange(0, 7)]
        [byte]$LeftLimitPin = 4,

        [ValidateRange(0, 7)]
        [byte]$RightLimitPin = 5,

        # Trigger polarity. Default (not set) = active-low: LOW = limit triggered.
        # Set -LimitActiveHigh when the switch pulls the pin HIGH when triggered.
        # Optical interrupters (photointerruptors) are typically active-low.
        [switch]$LimitActiveHigh
    )

    $log = $Ftdi.Logger

    # Compute bit positions
    [byte]$dirBit = [byte](1 -shl $DirPin)
    [byte]$pulBit = [byte](1 -shl $PulPin)

    [byte]$dirVal = if ($Direction -eq 'Forward') { $dirBit } else { [byte]0 }

    if ($NoEnable) {
        [byte]$outMask   = [byte]($dirBit -bor $pulBit)
        [byte]$baseByte  = [byte]$dirVal                        # DIR=set, PUL=0
        [byte]$pulseByte = [byte]($dirVal -bor $pulBit)         # DIR=set, PUL=1
    } else {
        [byte]$enaBit    = [byte](1 -shl $EnaPin)
        [byte]$outMask   = [byte]($enaBit -bor $dirBit -bor $pulBit)
        [byte]$baseByte  = [byte]($enaBit -bor $dirVal)         # ENA=1, DIR=set, PUL=0
        [byte]$pulseByte = [byte]($baseByte -bor $pulBit)       # ENA=1, DIR=set, PUL=1
    }

    $enaLabel = if ($NoEnable) { 'looped' } else { "C$EnaPin" }
    $log.WriteDebug("StepDirMove: $Steps steps / $Direction / delay=${DelayMs}ms / pulse=${PulseWidthUs}us / ENA=$enaLabel DIR=C$DirPin PUL=C$PulPin")
    $script:PsGadgetLogger.WriteProto('STEPPER',
        "STEPDIR $Steps steps $Direction ${DelayMs}ms/step ${PulseWidthUs}us pulse ENA=$enaLabel DIR=C$DirPin PUL=C$PulPin")

    $dirForward  = $Direction -eq 'Forward'
    $limitHit    = $false
    $stepsActual = $Steps

    $conn       = $Ftdi._connection
    $gpioMethod = if ($conn -and $conn.PSObject.Properties['GpioMethod']) { $conn.GpioMethod } else { '' }

    if ($conn -and $conn.PSObject.Properties['Device'] -and $conn.Device) {

        $sw            = [System.Diagnostics.Stopwatch]::new()
        $freq          = [System.Diagnostics.Stopwatch]::Frequency
        $stepTicks     = [long]($DelayMs      * ($freq / 1000.0))
        $pulseTicks    = [long]($PulseWidthUs * ($freq / 1000000.0))
        $dirSetupTicks = [long](5             * ($freq / 1000000.0))   # 5 µs DIR setup

        if ($gpioMethod -eq 'MPSSE' -or $gpioMethod -eq 'MpsseI2c') {
            # SET_BITS_HIGH (0x82) drives ACBUS C0-C7 while keeping ADBUS for I2C/SPI.
            $log.WriteInfo("StepDirMove: MPSSE ACBUS path, $Steps steps @ ${DelayMs}ms")
            $mpsseHigh = [byte[]]@(0x82, $pulseByte, $outMask)
            $mpsseLow  = [byte[]]@(0x82, $baseByte,  $outMask)
            [uint32]$written = 0
            [uint32]$read    = 0
            $readBuf         = [byte[]]::new(1)
            try {
                # Set initial state and wait DIR setup time before first pulse
                $conn.Device.Write($mpsseLow, 3, [ref]$written) | Out-Null
                $sw.Restart()
                while ($sw.ElapsedTicks -lt $dirSetupTicks) {}

                # Helper: given raw ACBUS byte, return $true if the specified pin is triggered.
                # Active-low (default): bit clear = triggered. Active-high: bit set = triggered.
                $isTriggered = if ($LimitActiveHigh) {
                    [scriptblock]{ param($b, $pin) ($b -band (1 -shl $pin)) -ne 0 }
                } else {
                    [scriptblock]{ param($b, $pin) ($b -band (1 -shl $pin)) -eq 0 }
                }

                # Pre-check: read limits before first step so we don't pulse into a hard stop
                if ($UseLimits) {
                    $conn.Device.Write([byte[]](0x83, 0x87), 2, [ref]$written) | Out-Null
                    $conn.Device.Read($readBuf, 1, [ref]$read) | Out-Null
                    $acbus = $readBuf[0]
                    if (($dirForward      -and (& $isTriggered $acbus $RightLimitPin)) -or
                        (-not $dirForward -and (& $isTriggered $acbus $LeftLimitPin))) {
                        $limitHit    = $true
                        $stepsActual = 0
                        $log.WriteInfo("StepDirMove: limit already triggered, no steps issued (MPSSE/ACBUS)")
                    }
                }

                if (-not $limitHit) {
                    for ($i = 0; $i -lt $Steps; $i++) {
                        $conn.Device.Write($mpsseHigh, 3, [ref]$written) | Out-Null
                        $sw.Restart()
                        while ($sw.ElapsedTicks -lt $pulseTicks) {}
                        $conn.Device.Write($mpsseLow, 3, [ref]$written) | Out-Null
                        $sw.Restart()
                        while ($sw.ElapsedTicks -lt $stepTicks) {}
                        if ($UseLimits) {
                            $conn.Device.Write([byte[]](0x83, 0x87), 2, [ref]$written) | Out-Null
                            $conn.Device.Read($readBuf, 1, [ref]$read) | Out-Null
                            $acbus = $readBuf[0]
                            if (($dirForward      -and (& $isTriggered $acbus $RightLimitPin)) -or
                                (-not $dirForward -and (& $isTriggered $acbus $LeftLimitPin))) {
                                $limitHit    = $true
                                $stepsActual = $i + 1
                                break
                            }
                        }
                    }
                }
                $log.WriteInfo("StepDirMove: completed $stepsActual/$Steps steps (MPSSE/ACBUS)$(if ($limitHit) { ' [limit hit]' })")
                $script:PsGadgetLogger.WriteProto('STEPPER', "DONE $stepsActual/$Steps steps MPSSE/ACBUS STEPDIR$(if ($limitHit) { ' LIMIT' })")
            } catch [System.NotImplementedException] {
                $log.WriteTrace("StepDirMove stub: FT_Write not implemented (no hardware)")
            } catch {
                $log.WriteError("StepDirMove FT_Write error: $($_.Exception.Message)")
                throw
            }

        } elseif ($gpioMethod -eq 'IoT') {
            # IoT GpioController: ACBUS pin N -> controller pin N+8
            $gpioCtrl = $conn.GpioController
            if (-not $gpioCtrl) { throw "StepDirMove: IoT connection is missing GpioController" }
            $dirIot = $DirPin + 8
            $pulIot = $PulPin + 8
            $iotPins = @($dirIot, $pulIot)
            if (-not $NoEnable) { $iotPins += ($EnaPin + 8) }
            foreach ($p in $iotPins) {
                if (-not $gpioCtrl.IsPinOpen($p)) {
                    $gpioCtrl.OpenPin($p, [System.Device.Gpio.PinMode]::Output)
                }
            }
            $log.WriteInfo("StepDirMove: IoT ACBUS path, $Steps steps @ ${DelayMs}ms")
            $high      = [System.Device.Gpio.PinValue]::High
            $low       = [System.Device.Gpio.PinValue]::Low
            $dirPinVal = if ($Direction -eq 'Forward') { $high } else { $low }

            # Open limit input pins once before the move
            if ($UseLimits) {
                $leftIot  = $LeftLimitPin  + 8
                $rightIot = $RightLimitPin + 8
                foreach ($lp in @($leftIot, $rightIot)) {
                    if (-not $gpioCtrl.IsPinOpen($lp)) {
                        $gpioCtrl.OpenPin($lp, [System.Device.Gpio.PinMode]::Input)
                    }
                }
            }

            try {
                if (-not $NoEnable) { $gpioCtrl.Write($EnaPin + 8, $high) }
                $gpioCtrl.Write($dirIot, $dirPinVal)
                $gpioCtrl.Write($pulIot, $low)
                $sw.Restart()
                while ($sw.ElapsedTicks -lt $dirSetupTicks) {}

                # Helper: return $true when the IoT pin reads the triggered state.
                # Active-low (default): Low = triggered. Active-high: High = triggered.
                $iotTriggered = if ($LimitActiveHigh) {
                    [scriptblock]{ param($pin) $gpioCtrl.Read($pin) -eq [System.Device.Gpio.PinValue]::High }
                } else {
                    [scriptblock]{ param($pin) $gpioCtrl.Read($pin) -eq [System.Device.Gpio.PinValue]::Low }
                }

                # Pre-check: read limits before first step
                if ($UseLimits) {
                    if (($dirForward      -and (& $iotTriggered $rightIot)) -or
                        (-not $dirForward -and (& $iotTriggered $leftIot))) {
                        $limitHit    = $true
                        $stepsActual = 0
                        $log.WriteInfo("StepDirMove: limit already triggered, no steps issued (IoT/ACBUS)")
                    }
                }

                if (-not $limitHit) {
                    for ($i = 0; $i -lt $Steps; $i++) {
                        $gpioCtrl.Write($pulIot, $high)
                        $sw.Restart()
                        while ($sw.ElapsedTicks -lt $pulseTicks) {}
                        $gpioCtrl.Write($pulIot, $low)
                        $sw.Restart()
                        while ($sw.ElapsedTicks -lt $stepTicks) {}
                        if ($UseLimits) {
                            if (($dirForward      -and (& $iotTriggered $rightIot)) -or
                                (-not $dirForward -and (& $iotTriggered $leftIot))) {
                                $limitHit    = $true
                                $stepsActual = $i + 1
                                break
                            }
                        }
                    }
                }
                $log.WriteInfo("StepDirMove: completed $stepsActual/$Steps steps (IoT/ACBUS)$(if ($limitHit) { ' [limit hit]' })")
                $script:PsGadgetLogger.WriteProto('STEPPER', "DONE $stepsActual/$Steps steps IoT/ACBUS STEPDIR$(if ($limitHit) { ' LIMIT' })")
            } catch {
                $log.WriteError("StepDirMove IoT error: $($_.Exception.Message)")
                throw
            }

        } else {
            throw ("StepDirMove: DriverType TB6600 requires MPSSE or IoT connection " +
                   "(current GpioMethod='$gpioMethod'). AsyncBitBang does not support ACBUS step/dir.")
        }

    } else {
        $log.WriteTrace("StepDirMove stub: no Device handle (Steps=$Steps, Dir=$Direction)")
    }

    return [PSCustomObject]@{
        Steps        = $Steps
        StepsActual  = $stepsActual
        Direction    = $Direction
        LimitHit     = $limitHit
        DelayMs      = $DelayMs
    }
}