Public/Invoke-PsGadgetStepper.ps1

#Requires -Version 5.1

function Invoke-PsGadgetStepper {
    <#
    .SYNOPSIS
    Drive a stepper motor connected to an FTDI device in a single command.
 
    .DESCRIPTION
    High-level stepper motor dispatch. Supports two driver types selected by
    -DriverType:
 
    ULN2003 (default) — coil-sequence driver for unipolar motors (e.g. 28BYJ-48).
        Opens the FTDI device, switches to async bit-bang on ADBUS0-3, pre-computes
        the full coil sequence as a byte buffer, and issues a single USB write so
        the D2XX baud-rate timer paces each transition. Supports FT232R and FT232H.
 
        Pin wiring (default):
            ADBUS0 (D0) -> IN1
            ADBUS1 (D1) -> IN2
            ADBUS2 (D2) -> IN3
            ADBUS3 (D3) -> IN4
 
        StepsPerRevolution default: ~4075.77 half-steps (28BYJ-48 empirical,
        NOT exactly 4096). Calibrate and pass -StepsPerRevolution to override.
 
    TB6600 — step/direction driver for bipolar motors (e.g. NEMA 17/23, VEXTA).
        Uses MPSSE SET_BITS_HIGH (0x82) to toggle ACBUS/CBUS pins. Requires
        FT232H in MPSSE or IoT mode. The driver handles all phase sequencing;
        the host only pulses PUL+ once per step.
 
        Default pin wiring (ACBUS/CBUS on FT232H):
            CBUS0 (C0) -> PUL+ (pulse: rising edge = 1 step)
            CBUS1 (C1) -> DIR+ (direction: HIGH=forward)
            CBUS2 (C2) -> ENA+ (enable; omit with -NoEnable when looped/GND)
            CBUS4 (C4) -> Left limit switch (input; LOW = triggered, active-low default)
            CBUS5 (C5) -> Right limit switch (input; LOW = triggered, active-low default)
        Override with -EnaPin / -DirPin / -PulPin / -LeftLimitPin / -RightLimitPin.
 
        StepsPerRevolution default: 200 (1.8-degree motor, full-step).
        Change to match your TB6600 microstep DIP setting, e.g. 400 for 1/2 step,
        800 for 1/4 step, 1600 for 1/8 step, 3200 for 1/16 step, 6400 for 1/32 step.
 
    .PARAMETER PsGadget
    An already-open PsGadgetFtdi object (from New-PsGadgetFtdi).
    When supplied the device is NOT closed after the call.
 
    .PARAMETER Index
    FTDI device index (0-based) from Get-FtdiDevice. Default is 0.
 
    .PARAMETER SerialNumber
    FTDI device serial number (e.g. "FTAXBFCQ").
    Preferred over Index for stable identification across USB re-plugs.
 
    .PARAMETER Steps
    Number of individual step pulses to issue.
    Mutually exclusive with -Degrees. Provide exactly one.
 
    .PARAMETER Degrees
    Rotate the output shaft by this many degrees.
    Converted to steps using StepsPerRevolution (calibrated or default).
    Mutually exclusive with -Steps. Provide exactly one.
 
    .PARAMETER StepsPerRevolution
    Number of step pulses per full output-shaft revolution for the
    configured StepMode. Default 0 = use built-in calibration value:
        Half mode: ~4075.77 (28BYJ-48 empirical, NOT exactly 4096)
        Full mode: ~2037.89 (half of above; each full-step moves twice as far)
    Supply your measured value to override. Valid range: 100-100000.
 
    .PARAMETER Direction
    'Forward' (default) or 'Reverse'.
 
    .PARAMETER StepMode
    'Half' (default, smoother, higher resolution) or 'Full' (higher torque).
    Half-step is recommended for 28BYJ-48.
 
    .PARAMETER DelayMs
    Inter-step delay in milliseconds. Controls baud rate timing.
    Minimum recommended for 28BYJ-48: 1 ms. Default: 2 ms.
    Lower values increase speed but risk stall on slower geared motors.
 
    .PARAMETER PinMask
    Output direction mask byte for ADBUS pins.
    Default 0x0F = ADBUS0-3 all outputs (IN1-IN4 on ULN2003).
 
    .PARAMETER PinOffset
    Shift coil byte left by N bits. Use when motor is wired on upper ADBUS pins.
    Default 0 (IN1=bit0 = ADBUS0).
 
    .PARAMETER AcBus
    (ULN2003 only) Target ACBUS (C-bank, pins C0-C7) instead of ADBUS (D-bank).
    Required when the stepper is wired to ACBUS C0-C3 on an FT232H that is
    also running I2C on ADBUS D0/D1 (e.g. combined stepper + SSD1306).
    Uses MPSSE SET_BITS_HIGH (0x82) instead of SET_BITS_LOW (0x80).
 
    .PARAMETER DriverType
    'ULN2003' (default) for coil-sequence unipolar drivers (28BYJ-48).
    'TB6600' for step/direction bipolar drivers (NEMA 17/23, VEXTA PK-series).
 
    .PARAMETER EnaPin
    (TB6600 only) ACBUS pin number for ENA+. Default 2 (CBUS2).
 
    .PARAMETER DirPin
    (TB6600 only) ACBUS pin number for DIR+. Default 1 (CBUS1).
 
    .PARAMETER PulPin
    (TB6600 only) ACBUS pin number for PUL+. Default 0 (CBUS0).
 
    .PARAMETER PulseWidthUs
    (TB6600 only) PUL+ high-pulse width in microseconds.
    TB6600 minimum is 2.5 µs; default 5 µs gives a safe margin.
 
    .PARAMETER NoEnable
    (TB6600 only) Skip ENA pin control entirely.
    Use when ENA+/ENA- are looped together or ENA+ is tied to GND (always-enabled).
 
    .PARAMETER UseLimits
    (TB6600 only) Enable limit switch checking after each step pulse.
    When set, ACBUS pins LeftLimitPin and RightLimitPin are sampled.
    A Forward move stops when the right limit fires; a Reverse move stops on the left.
    Default polarity: LOW = triggered (active-low, typical for optical interruptors).
    Use -LimitActiveHigh for switches that pull the pin HIGH when triggered.
 
    .PARAMETER LimitActiveHigh
    (TB6600 only) Switch trigger polarity. Default (not set) = active-low.
    Optical interruptors (photointerruptors) are active-low: beam broken -> LOW.
    Set this switch when your hardware pulls the pin HIGH when the limit is reached.
 
    .PARAMETER LeftLimitPin
    (TB6600 only) ACBUS pin for the left limit switch. Default 4 (CBUS4).
 
    .PARAMETER RightLimitPin
    (TB6600 only) ACBUS pin for the right limit switch. Default 5 (CBUS5).
 
    .EXAMPLE
    # Move forward 1000 half-steps (about 88 degrees) [ULN2003]
    Invoke-PsGadgetStepper -Index 0 -Steps 1000
 
    .EXAMPLE
    # Rotate 90 degrees using calibrated StepsPerRevolution [ULN2003]
    Invoke-PsGadgetStepper -Index 0 -Degrees 90
 
    .EXAMPLE
    # Reverse 180 degrees, full-step mode, faster speed [ULN2003]
    Invoke-PsGadgetStepper -Index 0 -Degrees 180 -Direction Reverse -StepMode Full -DelayMs 1
 
    .EXAMPLE
    # TB6600 - full step (200 steps/rev), forward 1 revolution
    # Wiring: ENA+=CBUS0, DIR+=CBUS1, PUL+=CBUS2 (defaults)
    Invoke-PsGadgetStepper -Index 0 -Steps 200 -DriverType TB6600
 
    .EXAMPLE
    # TB6600 - rotate 90 degrees, 1/8 microstep (1600 steps/rev), 5ms/step
    Invoke-PsGadgetStepper -Index 0 -Degrees 90 -DriverType TB6600 -StepsPerRevolution 1600 -DelayMs 5
 
    .EXAMPLE
    # TB6600 - reverse 180 degrees, 1/4 microstep
    Invoke-PsGadgetStepper -Index 0 -Degrees 180 -DriverType TB6600 -Direction Reverse -StepsPerRevolution 800
 
    .EXAMPLE
    # Use an already-open device (device stays open after call)
    $dev = New-PsGadgetFtdi -Index 0
    Invoke-PsGadgetStepper -PsGadget $dev -Steps 2048
 
    .OUTPUTS
    PSCustomObject with DriverType, Direction, Steps, Degrees, StepsPerRevolution,
    DelayMs, and Device.
 
    .NOTES
    When using -Index or -SerialNumber the FTDI device is opened and closed
    within this call. When using -PsGadget the caller retains ownership and
    the device is NOT closed after the call.
 
    ULN2003: device is left in AsyncBitBang mode after the call.
    TB6600: device stays in MPSSE mode; I2C/SPI can be used before or after.
    #>


    [CmdletBinding(DefaultParameterSetName = 'ByIndex')]
    [OutputType('PSCustomObject')]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'ByDevice', Position = 0)]
        [ValidateNotNull()]
        [object]$PsGadget,

        [Parameter(Mandatory = $false, ParameterSetName = 'ByIndex')]
        [ValidateRange(0, 127)]
        [int]$Index = 0,

        [Parameter(Mandatory = $true, ParameterSetName = 'BySerial')]
        [ValidateNotNullOrEmpty()]
        [string]$SerialNumber,

        # Motion specification - exactly one of -Steps or -Degrees is required
        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 2147483647)]
        [int]$Steps = 0,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0.001, 36000.0)]
        [double]$Degrees = -1,

        # Calibration: 0 = use mode-appropriate default from
        # Get-PsGadgetStepperDefaultStepsPerRev (~4075.77 half / ~2037.89 full)
        [Parameter(Mandatory = $false)]
        [ValidateRange(0.0, 100000.0)]
        [double]$StepsPerRevolution = 0,

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

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

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 1000)]
        [int]$DelayMs = 2,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0x01, 0xFF)]
        [byte]$PinMask = 0x0F,

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

        [Parameter(Mandatory = $false)]
        [switch]$AcBus,

        # --- TB6600 / step-dir parameters ---

        [Parameter(Mandatory = $false)]
        [ValidateSet('ULN2003', 'TB6600')]
        [string]$DriverType = 'ULN2003',

        # ACBUS pin numbers for ENA+, DIR+, PUL+ on the TB6600.
        # Defaults match: CBUS0=ENA+, CBUS1=DIR+, CBUS2=PUL+
        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 7)]
        [byte]$EnaPin = 2,

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

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 7)]
        [byte]$PulPin = 0,

        # TB6600 PUL+ high-pulse width. Minimum 2.5 µs per datasheet; 5 µs default.
        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 1000)]
        [int]$PulseWidthUs = 5,

        # Skip ENA pin control when ENA+/ENA- are looped or tied to GND (always-enabled).
        [Parameter(Mandatory = $false)]
        [switch]$NoEnable,

        # Limit switch support (TB6600 only).
        # When set, ACBUS pins LeftLimitPin / RightLimitPin are sampled after each step.
        # HIGH (~4.8V) = switch triggered. Move aborts when the relevant limit is hit.
        [Parameter(Mandatory = $false)]
        [switch]$UseLimits,

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

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

        # Trigger polarity. Default (not set) = active-low: LOW = limit triggered.
        # Optical/photointerruptor switches are typically active-low (beam broken -> LOW).
        # Set -LimitActiveHigh when your switches pull the pin HIGH when triggered.
        [Parameter(Mandatory = $false)]
        [switch]$LimitActiveHigh
    )

    process {
        # --- validate that exactly one motion spec was provided ---
        if ($Steps -le 0 -and $Degrees -lt 0) {
            throw "Invoke-PsGadgetStepper: supply either -Steps or -Degrees."
        }
        if ($Steps -gt 0 -and $Degrees -ge 0) {
            throw "Invoke-PsGadgetStepper: supply -Steps or -Degrees, not both."
        }

        # --- resolve StepsPerRevolution ---
        $spr = if ($StepsPerRevolution -gt 0) {
            $StepsPerRevolution
        } elseif ($DriverType -eq 'TB6600') {
            200.0    # 1.8-degree NEMA motor, full-step; override for microstep DIP setting
        } else {
            Get-PsGadgetStepperDefaultStepsPerRev -StepMode $StepMode
        }

        # --- convert degrees to steps ---
        $resolvedDegrees = -1.0
        if ($Degrees -ge 0) {
            $resolvedDegrees = $Degrees
            $Steps = [Math]::Max(1, [int][Math]::Round($Degrees / 360.0 * $spr))
            Write-Verbose "Invoke-PsGadgetStepper: $Degrees deg -> $Steps steps (spr=$spr)"
        }

        $ownsDevice = $PSCmdlet.ParameterSetName -ne 'ByDevice'
        $ftdi = $null
        try {
            # --- open device ---
            if ($PSCmdlet.ParameterSetName -eq 'ByDevice') {
                $ftdi = $PsGadget
                if (-not $ftdi -or -not $ftdi.IsOpen) {
                    throw "PsGadgetFtdi object is not open."
                }
                Write-Verbose "Invoke-PsGadgetStepper: using provided PsGadgetFtdi device"
            } elseif ($PSCmdlet.ParameterSetName -eq 'BySerial') {
                Write-Verbose "Invoke-PsGadgetStepper: opening FTDI device serial '$SerialNumber'"
                $ftdi = New-PsGadgetFtdi -SerialNumber $SerialNumber
            } else {
                Write-Verbose "Invoke-PsGadgetStepper: opening FTDI device index $Index"
                $ftdi = New-PsGadgetFtdi -Index $Index
            }

            if (-not $ftdi -or -not $ftdi.IsOpen) {
                throw "Failed to open FTDI device"
            }

            # --- execute move ---
            $moveResult = $null
            if ($DriverType -eq 'TB6600') {
                $moveResult = Invoke-PsGadgetStepDirMove `
                    -Ftdi          $ftdi `
                    -Steps         $Steps `
                    -Direction     $Direction `
                    -DelayMs       $DelayMs `
                    -EnaPin        $EnaPin `
                    -DirPin        $DirPin `
                    -PulPin        $PulPin `
                    -PulseWidthUs  $PulseWidthUs `
                    -NoEnable:$NoEnable `
                    -UseLimits:$UseLimits `
                    -LeftLimitPin    $LeftLimitPin `
                    -RightLimitPin   $RightLimitPin `
                    -LimitActiveHigh:$LimitActiveHigh
            } else {
                Invoke-PsGadgetStepperMove `
                    -Ftdi      $ftdi `
                    -Steps     $Steps `
                    -Direction $Direction `
                    -StepMode  $StepMode `
                    -DelayMs   $DelayMs `
                    -PinMask   $PinMask `
                    -PinOffset $PinOffset `
                    -AcBus:$AcBus
            }

            # --- return summary ---
            return [PSCustomObject]@{
                DriverType        = $DriverType
                StepMode          = if ($DriverType -eq 'TB6600') { $null } else { $StepMode }
                Direction         = $Direction
                Steps             = $Steps
                StepsActual       = if ($moveResult) { $moveResult.StepsActual } else { $Steps }
                LimitHit          = if ($moveResult) { $moveResult.LimitHit }   else { $false }
                Degrees           = if ($resolvedDegrees -ge 0) { [Math]::Round($resolvedDegrees, 4) } else { $null }
                StepsPerRevolution = [Math]::Round($spr, 4)
                DelayMs           = $DelayMs
                Device            = "$($ftdi.Description) ($($ftdi.SerialNumber))"
            }
        } finally {
            if ($ownsDevice -and $ftdi -and $ftdi.IsOpen) {
                Write-Verbose "Invoke-PsGadgetStepper: closing FTDI device"
                $ftdi.Close()
            }
        }
    }
}