Classes/PsGadgetFtdi.ps1
|
# PsGadgetFtdi Class # Represents an FTDI device connection with automatic logging. # Delegates to Connect-PsGadgetFtdi and Set-PsGadgetGpio public functions. class PsGadgetFtdi : System.IDisposable { [int]$Index [string]$SerialNumber [string]$LocationId [string]$Description [string]$Type [string]$GpioMethod [bool]$IsOpen [PsGadgetLogger]$Logger hidden [object]$_connection = $null hidden [object]$_display = $null # Keyed by "ModuleName:HexAddress" (e.g. "PCA9685:40"). # Stores initialized I2C device objects so re-calls skip construction + hardware init. hidden [hashtable]$_i2cDevices = $null # SSD1306 display height (32 or 64 pixels). Set before first GetDisplay() call. # Writable via New-PsGadgetFtdi -DisplayHeight 32 or directly: $dev.DisplayHeight = 32 [int]$DisplayHeight = 64 # Stepper motor calibration. # 0.0 = use mode-appropriate default from Get-PsGadgetStepperDefaultStepsPerRev: # Half: ~4075.77 Full: ~2037.89 (28BYJ-48 empirical, NOT 2048/4096) # Set to your measured value: $dev.StepsPerRevolution = 4082.5 [double]$StepsPerRevolution = 0.0 # Default step mode for .Step() and .StepDegrees() shorthand calls. [string]$DefaultStepMode = 'Half' # Constructor - connect by serial number (preferred) PsGadgetFtdi([string]$SerialNumber) { $this.SerialNumber = $SerialNumber $this.LocationId = '' $this.Index = -1 $this.IsOpen = $false $this.Description = "FTDI $SerialNumber" $this._i2cDevices = @{} $this.Logger = [PsGadgetLogger]::new() $this.Logger.WriteInfo("PsGadgetFtdi created for serial: $SerialNumber") } # Constructor - connect by device index PsGadgetFtdi([int]$DeviceIndex) { $this.Index = $DeviceIndex $this.SerialNumber = '' $this.LocationId = '' $this.IsOpen = $false $this.Description = "FTDI device index $DeviceIndex" $this._i2cDevices = @{} $this.Logger = [PsGadgetLogger]::new() $this.Logger.WriteInfo("PsGadgetFtdi created for index: $DeviceIndex") } # Open the device connection. # Calls the exported Connect-PsGadgetFtdi function and stores the connection object. [void] Connect() { if ($this.IsOpen) { $this.Logger.WriteInfo("Device already open: $($this.SerialNumber)$($this.Index)") return } $this.Logger.WriteInfo("Connecting to FTDI device...") try { $conn = $null if ($this.LocationId -ne '') { $conn = Connect-PsGadgetFtdi -LocationId $this.LocationId } elseif ($this.SerialNumber -ne '') { $conn = Connect-PsGadgetFtdi -SerialNumber $this.SerialNumber } else { $conn = Connect-PsGadgetFtdi -Index $this.Index } if (-not $conn) { throw "Connect-PsGadgetFtdi returned null" } $this._connection = $conn $this.IsOpen = $true $this.Type = $conn.Type $this.GpioMethod = $conn.GpioMethod $this.Description = $conn.Description if ($this.SerialNumber -eq '') { $this.SerialNumber = $conn.SerialNumber } if ($this.Index -lt 0) { $this.Index = $conn.Index } $this.Logger.WriteInfo("Connected: $($this.Description) ($($this.SerialNumber)) Type=$($this.Type) GPIO=$($this.GpioMethod)") } catch { $this.Logger.WriteError("Connect failed: $($_.Exception.Message)") throw } } # Close the device connection. [void] Close() { if (-not $this.IsOpen) { $this.Logger.WriteInfo("Close called but device is not open") return } $this.Logger.WriteInfo("Closing FTDI device: $($this.SerialNumber)") try { if ($this._connection -and $this._connection.Close) { $this._connection.Close() } } catch { $this.Logger.WriteError("Close error: $($_.Exception.Message)") } finally { $this.IsOpen = $false $this._connection = $null $this._i2cDevices = @{} # drop cached I2C device objects; stale on reconnect } } # IDisposable.Dispose() - enables try/finally and 'using' patterns. # Guarantees the D2XX handle is released even if the script errors mid-run. [void] Dispose() { $this.Logger.WriteInfo("Dispose()") $this.Close() } # Set a single GPIO pin by name: "HIGH"/"LOW"/"H"/"L"/"1"/"0" [void] SetPin([int]$Pin, [string]$State) { $this.Logger.WriteTrace("SetPin($Pin, $State)") if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new("Device not open. Call Connect() first.") } Set-PsGadgetGpio -Connection $this._connection -Pins @($Pin) -State $State } # Set a single GPIO pin by boolean (true = HIGH, false = LOW) [void] SetPin([int]$Pin, [bool]$High) { $state = if ($High) { 'HIGH' } else { 'LOW' } $this.SetPin($Pin, $state) } # Set the baud rate on an open connection (useful for async bit-bang timing) [void] SetBaudRate([uint32]$BaudRate) { if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new("Device not open. Call Connect() first.") } if (-not $this._connection -or -not ($this._connection | Get-Member -Name SetBaudRate -MemberType Method -ErrorAction SilentlyContinue)) { throw "Underlying connection object does not support SetBaudRate" } $status = $this._connection.SetBaudRate($BaudRate) if ($status -ne 0) { throw "SetBaudRate failed with status $status" } } # Set multiple GPIO pins simultaneously [void] SetPins([int[]]$Pins, [string]$State) { $this.Logger.WriteTrace("SetPins([$($Pins -join ',')] $State)") if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new("Device not open. Call Connect() first.") } Set-PsGadgetGpio -Connection $this._connection -Pins $Pins -State $State } # Set multiple GPIO pins by boolean [void] SetPins([int[]]$Pins, [bool]$High) { $state = if ($High) { 'HIGH' } else { 'LOW' } $this.SetPins($Pins, $state) } # Pulse a pin: set to State for DurationMs then invert [void] PulsePin([int]$Pin, [string]$State, [int]$DurationMs) { $this.Logger.WriteTrace("PulsePin($Pin, $State, ${DurationMs}ms)") if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new("Device not open. Call Connect() first.") } Set-PsGadgetGpio -Connection $this._connection -Pins @($Pin) -State $State -DurationMs $DurationMs } # Write raw bytes to the device [void] Write([byte[]]$Data) { $this.Logger.WriteTrace("Write $($Data.Length) bytes") if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new("Device not open. Call Connect() first.") } if (-not $this._connection -or -not $this._connection.Device) { throw [System.InvalidOperationException]::new("No underlying device handle available") } [uint32]$written = 0 $status = $this._connection.Device.Write($Data, [uint32]$Data.Length, [ref]$written) $this.Logger.WriteInfo("Write $written/$($Data.Length) bytes status=$status") } # Read raw bytes from the device [byte[]] Read([int]$Count) { $this.Logger.WriteTrace("Read $Count bytes") if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new("Device not open. Call Connect() first.") } if (-not $this._connection -or -not $this._connection.Device) { throw [System.InvalidOperationException]::new("No underlying device handle available") } $buf = [byte[]]::new($Count) [uint32]$bytesRead = 0 $status = $this._connection.Device.Read($buf, [uint32]$Count, [ref]$bytesRead) $this.Logger.WriteInfo("Read $bytesRead/$Count bytes status=$status") if ($bytesRead -lt $Count) { $trimmed = [byte[]]::new($bytesRead) [System.Array]::Copy($buf, $trimmed, $bytesRead) return $trimmed } return $buf } # Soft reset - clears internal buffers and resets chip state, handle stays open [void] Reset() { $this.Logger.WriteInfo("Reset()") if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new("Device not open. Call Connect() first.") } if (-not $this._connection -or -not $this._connection.Device) { throw [System.InvalidOperationException]::new("No underlying device handle available") } $status = $this._connection.Device.ResetDevice() $this.Logger.WriteInfo("ResetDevice status=$status") if ([int]$status -ne 0) { throw "ResetDevice failed: $status" } } # GetDisplay() - returns the cached PsGadgetSsd1306 object, lazily initializing it on first call. # Use this to access advanced formatting options (Align, FontSize, Invert) that Display() does not expose. # The same object is reused by Display() and ClearDisplay() -- no double-init conflicts. # # Usage: # $d = $dev.GetDisplay() # $d.WriteText("Clock", 0, 'center', 2, $false) # $d.ClearPage(0) [PsGadgetSsd1306] GetDisplay() { return $this.GetDisplay(0x3C, $this.DisplayHeight) } [PsGadgetSsd1306] GetDisplay([byte]$Address) { return $this.GetDisplay($Address, $this.DisplayHeight) } [PsGadgetSsd1306] GetDisplay([byte]$Address, [int]$Height) { if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new('Device not open. Call Connect() first.') } # Invalidate cached display if height changed (e.g. caller corrects after first use). if ($this._display -and $this._display.IsInitialized -and $this._display.Height -ne $Height) { $this.Logger.WriteInfo("DisplayHeight changed ($($this._display.Height) -> $Height): reinitializing SSD1306") $this._display = $null } if (-not $this._display -or -not $this._display.IsInitialized) { Write-Host "[SSD1306] 0x$($Address.ToString('X2')) ready (128x${Height})" $ssd = [PsGadgetSsd1306]::new($this._connection, $Address, $Height) $ssd.Initialize($false) | Out-Null if (-not $ssd.IsInitialized) { throw [System.InvalidOperationException]::new('Failed to initialize SSD1306 display at 0x' + $Address.ToString('X2')) } $this._display = $ssd } return $this._display } # Display() - write text to the SSD1306 OLED (left-aligned, FontSize 1). # For alignment/FontSize/Invert use $dev.GetDisplay() then call .WriteText() directly. [void] Display([string]$Text) { $this.Display($Text, 0, 0x3C) } [void] Display([string]$Text, [int]$Page) { $this.Display($Text, $Page, 0x3C) } [void] Display([string]$Text, [int]$Page, [byte]$Address) { $this.Logger.WriteTrace("Display('$Text', page=$Page, addr=0x$($Address.ToString('X2')))") $this.GetDisplay($Address).WriteText($Text, $Page, 'left', 1, $false) | Out-Null } # ClearDisplay() - clear all pages or a single page. [void] ClearDisplay() { $this.ClearDisplay(-1, 0x3C) } [void] ClearDisplay([int]$Page) { $this.ClearDisplay($Page, 0x3C) } [void] ClearDisplay([int]$Page, [byte]$Address) { $this.Logger.WriteTrace("ClearDisplay(page=$Page, addr=0x$($Address.ToString('X2')))") if ($Page -ge 0) { $this.GetDisplay($Address).ClearPage($Page) | Out-Null } else { $this.GetDisplay($Address).Clear() | Out-Null } } # Scan for I2C devices on the bus (0x08 to 0x77). # Requires an MPSSE-capable device (FT232H) and an open connection. # IoT backend uses .NET IoT I2cBus; D2XX backend uses MPSSE bit-bang. # Returns an array of [PSCustomObject]@{ Address; Hex } for each ACK. [System.Object[]] ScanI2CBus() { $this.Logger.WriteInfo('ScanI2CBus() - I2C bus scan 0x08-0x77') if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new('Device not open. Call Connect() first.') } if ($this.GpioMethod -notin @('MPSSE', 'IoT', '')) { throw [System.InvalidOperationException]::new( "I2C scan requires an MPSSE device (FT232H). This device uses GpioMethod=$($this.GpioMethod).") } $devices = Invoke-FtdiI2CScan -Connection $this._connection $this.Logger.WriteInfo("ScanI2CBus() found $($devices.Count) device(s)") return $devices } # USB port cycle - equivalent to physically unplugging and replugging the device. # Triggers re-enumeration so EEPROM changes (e.g. CBUS mode) take effect without # a manual replug. D2XX automatically closes the handle after CyclePort succeeds. # Call Connect() again after this to reopen. [void] CyclePort() { $this.Logger.WriteInfo("CyclePort()") if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new("Device not open. Call Connect() first.") } if (-not $this._connection -or -not $this._connection.Device) { throw [System.InvalidOperationException]::new("No underlying device handle available") } # CyclePort calls FT_Close internally on success - mark as closed regardless $status = $this._connection.Device.CyclePort() $this.IsOpen = $false $this._connection = $null $this.Logger.WriteInfo("CyclePort status=$status (handle released)") if ([int]$status -ne 0) { throw "CyclePort failed: $status" } $this.Logger.WriteInfo("USB port cycled - device will re-enumerate. Call Connect() to reopen.") } # --------------------------------------------------------------------------- # Stepper motor shorthand methods. # Delegates to Invoke-PsGadgetStepperMove (Private/Stepper.Backend.ps1). # # StepsPerRevolution and DefaultStepMode are instance properties so the # same device object can be calibrated once and reused across many calls: # $dev.StepsPerRevolution = 4082.5 # $dev.DefaultStepMode = 'Half' # $dev.StepDegrees(90) # --------------------------------------------------------------------------- # Step() - move N individual step pulses. [void] Step([int]$Steps) { $this._Step($Steps, 'Forward', $this.DefaultStepMode, 2, 0x0F) } [void] Step([int]$Steps, [string]$Direction) { $this._Step($Steps, $Direction, $this.DefaultStepMode, 2, 0x0F) } [void] Step([int]$Steps, [string]$Direction, [string]$StepMode) { $this._Step($Steps, $Direction, $StepMode, 2, 0x0F) } [void] Step([int]$Steps, [string]$Direction, [string]$StepMode, [int]$DelayMs) { $this._Step($Steps, $Direction, $StepMode, $DelayMs, 0x0F) } # Full overload: explicit PinMask. [void] Step([int]$Steps, [string]$Direction, [string]$StepMode, [int]$DelayMs, [byte]$PinMask) { $this._Step($Steps, $Direction, $StepMode, $DelayMs, $PinMask) } hidden [void] _Step([int]$Steps, [string]$Direction, [string]$StepMode, [int]$DelayMs, [byte]$PinMask) { $this.Logger.WriteTrace("Step($Steps, $Direction, $StepMode, delay=${DelayMs}ms)") if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new("Device not open. Call Connect() first.") } Invoke-PsGadgetStepperMove -Ftdi $this -Steps $Steps -Direction $Direction ` -StepMode $StepMode -DelayMs $DelayMs -PinMask $PinMask } # StepDegrees() - rotate by angle using StepsPerRevolution calibration. # Uses $this.StepsPerRevolution if set (>0); otherwise falls back to # Get-PsGadgetStepperDefaultStepsPerRev for the active step mode. [void] StepDegrees([double]$Degrees) { $this._StepDegrees($Degrees, 'Forward', $this.DefaultStepMode, 2, 0x0F, $this.StepsPerRevolution) } [void] StepDegrees([double]$Degrees, [string]$Direction) { $this._StepDegrees($Degrees, $Direction, $this.DefaultStepMode, 2, 0x0F, $this.StepsPerRevolution) } [void] StepDegrees([double]$Degrees, [string]$Direction, [string]$StepMode) { $this._StepDegrees($Degrees, $Direction, $StepMode, 2, 0x0F, $this.StepsPerRevolution) } # Full overload: explicit calibration override. [void] StepDegrees([double]$Degrees, [string]$Direction, [string]$StepMode, [double]$StepsPerRevolution) { $this._StepDegrees($Degrees, $Direction, $StepMode, 2, 0x0F, $StepsPerRevolution) } hidden [void] _StepDegrees([double]$Degrees, [string]$Direction, [string]$StepMode, [int]$DelayMs, [byte]$PinMask, [double]$StepsPerRevolution) { $this.Logger.WriteTrace("StepDegrees($Degrees, $Direction, $StepMode, spr=$StepsPerRevolution)") if (-not $this.IsOpen) { throw [System.InvalidOperationException]::new("Device not open. Call Connect() first.") } $spr = if ($StepsPerRevolution -gt 0.0) { $StepsPerRevolution } else { Get-PsGadgetStepperDefaultStepsPerRev -StepMode $StepMode } $steps = [Math]::Max(1, [int][Math]::Round($Degrees / 360.0 * $spr)) $this.Logger.WriteInfo("StepDegrees: $Degrees deg -> $steps steps (spr=$spr, mode=$StepMode)") Invoke-PsGadgetStepperMove -Ftdi $this -Steps $steps -Direction $Direction ` -StepMode $StepMode -DelayMs $DelayMs -PinMask $PinMask } } |