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. Opens an FTDI device, switches it to
    async bit-bang mode on ADBUS0-3, drives the attached stepper motor the
    requested number of steps or degrees, then closes the connection.
 
    Jitter reduction: the full step sequence is pre-computed as a byte buffer
    and written in a single USB transfer. The D2XX baud-rate timer paces each
    coil transition, eliminating per-step USB round-trip latency.
 
    Supports FT232R and FT232H (both use ADBUS async bit-bang for stepper).
 
    Pin wiring (default, ULN2003 driver board):
        ADBUS0 (D0) -> IN1
        ADBUS1 (D1) -> IN2
        ADBUS2 (D2) -> IN3
        ADBUS3 (D3) -> IN4
 
    StepsPerRevolution calibration note:
        The 28BYJ-48 is NOT exactly 2048 full-steps or 4096 half-steps per
        revolution. Empirical measurement yields ~4075.77 half-steps/rev.
        The default is that value. Calibrate your specific unit and pass
        -StepsPerRevolution to use your measured value. Angle-based moves
        (-Degrees) always use the configured calibration.
 
    .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-FTDevice. 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
    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).
 
    .EXAMPLE
    # Move forward 1000 half-steps (about 88 degrees)
    Invoke-PsGadgetStepper -Index 0 -Steps 1000
 
    .EXAMPLE
    # Rotate 90 degrees using calibrated StepsPerRevolution
    Invoke-PsGadgetStepper -Index 0 -Degrees 90
 
    .EXAMPLE
    # Rotate 90 degrees using a measured calibration value
    Invoke-PsGadgetStepper -Index 0 -Degrees 90 -StepsPerRevolution 4082.5
 
    .EXAMPLE
    # Reverse 180 degrees, full-step mode, faster speed
    Invoke-PsGadgetStepper -Index 0 -Degrees 180 -Direction Reverse -StepMode Full -DelayMs 1
 
    .EXAMPLE
    # Use an already-open device (device stays open after call)
    $dev = New-PsGadgetFtdi -Index 0
    Invoke-PsGadgetStepper -PsGadget $dev -Steps 2048
 
    .EXAMPLE
    # Shorthand via PsGadgetFtdi object methods
    $dev = New-PsGadgetFtdi -Index 0
    $dev.Step(1000) # 1000 half-steps forward
    $dev.StepDegrees(90) # ~90 degrees using default calibration
    $dev.StepDegrees(90, 'Reverse') # 90 degrees reverse
    $dev.StepsPerRevolution = 4082.5 # apply measured calibration
    $dev.StepDegrees(180) # uses calibrated value
 
    .OUTPUTS
    PSCustomObject with StepMode, 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.
 
    The device is left in AsyncBitBang mode after the call. If you need to
    reuse the device for I2C or other protocols, call:
        Set-PsGadgetFtdiMode -PsGadget $dev -Mode UART
    before the next operation.
    #>


    [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
    )

    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
        } 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 ---
            Invoke-PsGadgetStepperMove `
                -Ftdi      $ftdi `
                -Steps     $Steps `
                -Direction $Direction `
                -StepMode  $StepMode `
                -DelayMs   $DelayMs `
                -PinMask   $PinMask `
                -PinOffset $PinOffset `
                -AcBus:$AcBus

            # --- return summary ---
            return [PSCustomObject]@{
                StepMode          = $StepMode
                Direction         = $Direction
                Steps             = $Steps
                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()
            }
        }
    }
}