Public/Invoke-PsGadgetI2C.ps1
|
#Requires -Version 5.1 function Invoke-PsGadgetI2C { <# .SYNOPSIS Configures an I2C peripheral attached to an FTDI device in a single command. .DESCRIPTION High-level I2C dispatch function. Connects to an FTDI device, configures it for MPSSE I2C, initialises the selected I2C module, drives it with the supplied parameters, then closes the connection automatically. -I2CModule selects the target chip and activates module-specific parameters. Currently supports: PCA9685 16-channel 12-bit PWM controller (RC servos, LEDs, fans). Add -ServoAngle to set one or more channels: Single channel: -ServoAngle @(0, 90) Multiple channels: -ServoAngle @(@(0,90), @(1,180), @(2,45)) SSD1306 128x64 I2C OLED display (default address 0x3C). -Text Write a single line of text to a page (0-7). -FontSize 2 renders double-height text spanning page and page+1. -Symbol Draw a named 8x8/16x16 sysadmin icon. -Clear Blank the display or a specific page. Supported symbols: Warning, Alert, Checkmark, Error, Info, Lock, Unlock, Network. .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 I2CAddress I2C address of the target module. Default is 0x40 (PCA9685 standard address). .PARAMETER I2CModule The I2C module type. Activates module-specific dynamic parameters. Supported values: 'PCA9685', 'SSD1306' .PARAMETER Frequency PWM frequency in Hz. Default is 50 (standard RC servo frequency). Valid range: 23-1526 Hz. Only used when -I2CModule is 'PCA9685'. .PARAMETER ServoAngle (dynamic - PCA9685 only) Channel and servo angle specification. Two accepted shapes: Single pair: @(<channel>, <degrees>) e.g. @(0, 90) Multiple pairs (any collection of 2-element arrays): @(@(<ch>, <deg>), @(<ch>, <deg>), ...) e.g. @(@(0, 90), @(1, 180), @(2, 45)) Channel range: 0-15 Degrees range: 0-180 .PARAMETER PulseMinUs Minimum servo pulse width in microseconds. Default is 500 (0.5 ms). Adjust for servos with non-standard pulse ranges. Valid range: 100-3000. .PARAMETER PulseMaxUs Maximum servo pulse width in microseconds. Default is 2500 (2.5 ms). Adjust for servos with non-standard pulse ranges. Valid range: 100-3000. .EXAMPLE # Move servo on channel 0 to 90 degrees Invoke-PsGadgetI2C -Index 0 -I2CModule PCA9685 -ServoAngle @(0, 90) .EXAMPLE # Move servos on channels 0, 1, 2 simultaneously Invoke-PsGadgetI2C -Index 0 -I2CModule PCA9685 -ServoAngle @(@(0,90), @(1,180), @(2,45)) .EXAMPLE # Use an already-open device object (device stays open after call) $dev = New-PsGadgetFtdi -Index 0 Invoke-PsGadgetI2C -PsGadget $dev -I2CModule PCA9685 -ServoAngle @(0, 90) .EXAMPLE # Use device serial number (stable across replug) Invoke-PsGadgetI2C -SerialNumber "FTAXBFCQ" -I2CModule PCA9685 -ServoAngle @(0, 0) .EXAMPLE # 200 Hz frequency for LED dimming (not servo) Invoke-PsGadgetI2C -Index 0 -I2CModule PCA9685 -Frequency 200 -ServoAngle @(0, 90) .OUTPUTS PSCustomObject with Module, Address, Frequency, and ChannelsSet (array of Channel/Degrees records). .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. #> [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, [Parameter(Mandatory = $false)] [ValidateRange(0x08, 0x77)] [byte]$I2CAddress = 0x40, [Parameter(Mandatory = $true)] [ValidateSet('PCA9685','SSD1306')] [string]$I2CModule, [Parameter(Mandatory = $false)] [ValidateRange(23, 1526)] [int]$Frequency = 50, [Parameter(Mandatory = $false)] [ValidateRange(100, 3000)] [int]$PulseMinUs = 500, [Parameter(Mandatory = $false)] [ValidateRange(100, 3000)] [int]$PulseMaxUs = 2500 ) DynamicParam { $dynParams = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new() if ($PSBoundParameters['I2CModule'] -eq 'PCA9685') { # ServoAngle (mandatory) $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $true $paramAttr.HelpMessage = 'Single @(channel,degrees) or array of pairs @(@(ch,deg),...)' $attrs.Add($paramAttr) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new( 'ServoAngle', [object[]], $attrs ) $dynParams.Add('ServoAngle', $rp) } if ($PSBoundParameters['I2CModule'] -eq 'SSD1306') { # Text $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $false $attrs.Add($paramAttr) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new('Text', [string], $attrs) $dynParams.Add('Text', $rp) # Page $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $false $attrs.Add($paramAttr) $attrs.Add([System.Management.Automation.ValidateRangeAttribute]::new(0, 15)) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new('Page', [int], $attrs) $dynParams.Add('Page', $rp) # Align $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $false $attrs.Add($paramAttr) $attrs.Add([System.Management.Automation.ValidateSetAttribute]::new([string[]]@('left','center','right'))) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new('Align', [string], $attrs) $dynParams.Add('Align', $rp) # FontSize $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $false $attrs.Add($paramAttr) $attrs.Add([System.Management.Automation.ValidateRangeAttribute]::new(1, 2)) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new('FontSize', [int], $attrs) $dynParams.Add('FontSize', $rp) # Invert $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $false $attrs.Add($paramAttr) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new('Invert', [switch], $attrs) $dynParams.Add('Invert', $rp) # Symbol $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $false $attrs.Add($paramAttr) $attrs.Add([System.Management.Automation.ValidateSetAttribute]::new([string[]]@('Warning','Alert','Checkmark','Error','Info','Lock','Unlock','Network'))) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new('Symbol', [string], $attrs) $dynParams.Add('Symbol', $rp) # Clear $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $false $attrs.Add($paramAttr) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new('Clear', [switch], $attrs) $dynParams.Add('Clear', $rp) # DisplayHeight $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $false $attrs.Add($paramAttr) $attrs.Add([System.Management.Automation.ValidateSetAttribute]::new([string[]]@('32','64'))) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new('DisplayHeight', [int], $attrs) $dynParams.Add('DisplayHeight', $rp) # Column $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $false $attrs.Add($paramAttr) $attrs.Add([System.Management.Automation.ValidateRangeAttribute]::new(0, 127)) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new('Column', [int], $attrs) $dynParams.Add('Column', $rp) # Rotation $attrs = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() $paramAttr = [System.Management.Automation.ParameterAttribute]::new() $paramAttr.Mandatory = $false $attrs.Add($paramAttr) $attrs.Add([System.Management.Automation.ValidateSetAttribute]::new([string[]]@('0','90','180','270'))) $rp = [System.Management.Automation.RuntimeDefinedParameter]::new('Rotation', [int], $attrs) $dynParams.Add('Rotation', $rp) } return $dynParams } process { $ownsDevice = $PSCmdlet.ParameterSetName -ne 'ByDevice' $ftdi = $null try { if ($PSCmdlet.ParameterSetName -eq 'ByDevice') { $ftdi = $PsGadget if (-not $ftdi -or -not $ftdi.IsOpen) { throw "PsGadgetFtdi object is not open." } Write-Verbose "Using provided PsGadgetFtdi device" } elseif ($PSCmdlet.ParameterSetName -eq 'BySerial') { Write-Verbose "Opening FTDI device serial '$SerialNumber'" $ftdi = New-PsGadgetFtdi -SerialNumber $SerialNumber } else { Write-Verbose "Opening FTDI device index $Index" $ftdi = New-PsGadgetFtdi -Index $Index } if (-not $ftdi -or -not $ftdi.IsOpen) { throw "Failed to open FTDI device" } # --- apply module-specific address default if not supplied by caller --- if (-not $PSBoundParameters.ContainsKey('I2CAddress')) { if ($I2CModule -eq 'SSD1306') { $I2CAddress = 0x3C } # PCA9685 keeps its param-block default of 0x40 } # --- configure MPSSE I2C (skip if already initialized this session) --- if ($null -eq $ftdi._connection -or $ftdi._connection.GpioMethod -ne 'MpsseI2c') { Write-Verbose "Setting FTDI to MpsseI2c mode" Set-PsGadgetFtdiMode -PsGadget $ftdi -Mode MpsseI2c | Out-Null } else { Write-Verbose "FTDI already in MpsseI2c mode" } # --- dispatch to module handler --- switch ($I2CModule) { 'PCA9685' { $pulseMinUs = $PulseMinUs $pulseMaxUs = $PulseMaxUs return Invoke-PsGadgetI2CPca9685 ` -Ftdi $ftdi ` -I2CAddress $I2CAddress ` -Frequency $Frequency ` -ServoAngle $PSBoundParameters['ServoAngle'] ` -PulseMinUs $pulseMinUs ` -PulseMaxUs $pulseMaxUs } 'SSD1306' { $text = $PSBoundParameters['Text'] $page = if ($PSBoundParameters.ContainsKey('Page')) { $PSBoundParameters['Page'] } else { -1 } $align = if ($PSBoundParameters['Align']) { $PSBoundParameters['Align'] } else { 'left' } $fontSize = if ($PSBoundParameters.ContainsKey('FontSize')) { $PSBoundParameters['FontSize'] } else { 1 } $invert = [bool]$PSBoundParameters['Invert'] $symbol = $PSBoundParameters['Symbol'] $clear = [bool]$PSBoundParameters['Clear'] $column = if ($PSBoundParameters.ContainsKey('Column')) { $PSBoundParameters['Column'] } else { 0 } $displayHeight = if ($PSBoundParameters.ContainsKey('DisplayHeight')) { $PSBoundParameters['DisplayHeight'] } else { 64 } $rotation = if ($PSBoundParameters.ContainsKey('Rotation')) { $PSBoundParameters['Rotation'] } else { 0 } return Invoke-PsGadgetI2CSsd1306 ` -Ftdi $ftdi ` -I2CAddress $I2CAddress ` -DisplayHeight $displayHeight ` -Rotation $rotation ` -Text $text ` -Page $page ` -Align $align ` -FontSize $fontSize ` -Invert:$invert ` -Symbol $symbol ` -Clear:$clear ` -Column $column } } } finally { if ($ownsDevice -and $ftdi -and $ftdi.IsOpen) { Write-Verbose "Closing FTDI device" $ftdi.Close() } } } } # --------------------------------------------------------------------------- # Private helper: PCA9685 dispatch # Not exported. Called only by Invoke-PsGadgetI2C. # --------------------------------------------------------------------------- function Invoke-PsGadgetI2CPca9685 { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$Ftdi, [Parameter(Mandatory = $true)] [byte]$I2CAddress, [Parameter(Mandatory = $true)] [int]$Frequency, [Parameter(Mandatory = $true)] [object[]]$ServoAngle, [Parameter(Mandatory = $false)] [int]$PulseMinUs = 500, [Parameter(Mandatory = $false)] [int]$PulseMaxUs = 2500 ) # --- parse ServoAngle into normalised list of [channel, degrees] pairs --- # Shape 1: flat 2-element int array @(0, 90) -> single pair # Shape 2: array of 2-element arrays @(@(0,90),@(1,180)) -> multiple pairs $pairs = @() if ($ServoAngle[0] -is [int] -or $ServoAngle[0] -is [long]) { # Flat pair - treat as single channel specification if ($ServoAngle.Count -ne 2) { throw "ServoAngle flat pair must have exactly 2 elements: @(channel, degrees). Got $($ServoAngle.Count) elements." } $pairs = @(, @([int]$ServoAngle[0], [int]$ServoAngle[1])) } else { # Array of pairs foreach ($item in $ServoAngle) { $arr = @($item) if ($arr.Count -ne 2) { throw "Each ServoAngle pair must have exactly 2 elements: @(channel, degrees). Got $($arr.Count) elements in one pair." } $pairs += , @([int]$arr[0], [int]$arr[1]) } } if ($pairs.Count -eq 0) { throw "ServoAngle must contain at least one channel/degrees pair." } # --- validate all pairs before touching hardware --- foreach ($pair in $pairs) { $ch = $pair[0] $deg = $pair[1] if ($ch -lt 0 -or $ch -gt 15) { throw "Channel $ch is out of range. Valid range: 0-15." } if ($deg -lt 0 -or $deg -gt 180) { throw "Degrees $deg is out of range. Valid range: 0-180." } } # --- get or create PCA9685 instance (cached per FTDI device + address) --- $cacheKey = "PCA9685:$($I2CAddress.ToString('X2'))" $pca = $null if ($Ftdi._i2cDevices -and $Ftdi._i2cDevices.ContainsKey($cacheKey)) { $pca = $Ftdi._i2cDevices[$cacheKey] if ($pca.Frequency -ne $Frequency) { Write-Verbose "PCA9685 frequency changed ($($pca.Frequency)->$Frequency Hz), reinitializing" $pca.Frequency = $Frequency if (-not $pca.Initialize($true)) { throw "PCA9685 reinitialize failed at address 0x$($I2CAddress.ToString('X2'))" } } else { Write-Verbose "Using cached PCA9685 at 0x$($I2CAddress.ToString('X2')) ($($pca.Frequency) Hz)" } if ($pca.PulseMinUs -ne $PulseMinUs -or $pca.PulseMaxUs -ne $PulseMaxUs) { Write-Verbose "PCA9685 pulse range updated: $($pca.PulseMinUs)-$($pca.PulseMaxUs) -> $PulseMinUs-$PulseMaxUs us" $pca.PulseMinUs = $PulseMinUs $pca.PulseMaxUs = $PulseMaxUs } } else { Write-Verbose "Creating PCA9685 at I2C address 0x$($I2CAddress.ToString('X2')), frequency $Frequency Hz" $pca = [PsGadgetPca9685]::new($Ftdi._connection, $I2CAddress) $pca.Frequency = $Frequency $pca.PulseMinUs = $PulseMinUs $pca.PulseMaxUs = $PulseMaxUs if (-not $pca.Initialize($false)) { throw "PCA9685 Initialize() failed at address 0x$($I2CAddress.ToString('X2'))" } if ($Ftdi._i2cDevices) { $Ftdi._i2cDevices[$cacheKey] = $pca } } # --- set channels --- $channelsSet = @() foreach ($pair in $pairs) { $ch = $pair[0] $deg = $pair[1] Write-Verbose "PCA9685 ch$ch -> $deg deg" if (-not $pca.SetChannel($ch, $deg)) { throw "PCA9685 SetChannel failed: channel=$ch degrees=$deg" } $channelsSet += [PSCustomObject]@{ Channel = $ch; Degrees = $deg } } # --- return summary --- return [PSCustomObject]@{ Module = 'PCA9685' Address = '0x' + $I2CAddress.ToString('X2') Frequency = $Frequency ChannelsSet = $channelsSet } } # --------------------------------------------------------------------------- # Private helper: SSD1306 dispatch # Not exported. Called only by Invoke-PsGadgetI2C. # --------------------------------------------------------------------------- function Invoke-PsGadgetI2CSsd1306 { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$Ftdi, [Parameter(Mandatory = $true)] [byte]$I2CAddress, [Parameter(Mandatory = $false)] [ValidateSet(32, 64)] [int]$DisplayHeight = 64, [Parameter(Mandatory = $false)] [ValidateSet(0, 90, 180, 270)] [int]$Rotation = 0, [Parameter(Mandatory = $false)] [string]$Text, [Parameter(Mandatory = $false)] [int]$Page = -1, [Parameter(Mandatory = $false)] [string]$Align = 'left', [Parameter(Mandatory = $false)] [int]$FontSize = 1, [Parameter(Mandatory = $false)] [switch]$Invert, [Parameter(Mandatory = $false)] [string]$Symbol, [Parameter(Mandatory = $false)] [switch]$Clear, [Parameter(Mandatory = $false)] [int]$Column = 0 ) # --- validate mutual exclusions --- if ($Text -and $Symbol) { throw "Specify either -Text or -Symbol, not both." } if (($Text -or $Symbol) -and $Page -lt 0) { throw "-Page is required when using -Text or -Symbol." } # --- get or create cached SSD1306 instance --- # Cache key includes height; rotation is not part of key since SetRotation() updates in place. $cacheKey = "SSD1306:$($I2CAddress.ToString('X2')):$DisplayHeight" $ssd = $null if ($Ftdi._i2cDevices -and $Ftdi._i2cDevices.ContainsKey($cacheKey)) { $ssd = $Ftdi._i2cDevices[$cacheKey] Write-Verbose "Using cached SSD1306 ($DisplayHeight px) at 0x$($I2CAddress.ToString('X2'))" } else { Write-Information "[SSD1306] 0x$($I2CAddress.ToString('X2')) ready (128x${DisplayHeight})" -InformationAction Continue $ssd = [PsGadgetSsd1306]::new($Ftdi._connection, $I2CAddress, [int]$DisplayHeight) if (-not $ssd.Initialize($false)) { throw "SSD1306 Initialize() failed at address 0x$($I2CAddress.ToString('X2'))" } if ($Ftdi._i2cDevices) { $Ftdi._i2cDevices[$cacheKey] = $ssd } } # Apply rotation (no-op if already at the requested rotation) if ($ssd.Rotation -ne $Rotation) { Write-Verbose "SSD1306 rotation $($ssd.Rotation) -> $Rotation" $ssd.SetRotation($Rotation) } # --- dispatch --- $action = 'init' if ($Clear) { if ($Page -ge 0) { if (-not $ssd.ClearPage($Page)) { throw "SSD1306 ClearPage($Page) failed" } $action = "clear-page-$Page" } else { if (-not $ssd.Clear()) { throw "SSD1306 Clear() failed" } $action = 'clear' } } elseif ($Symbol) { if (-not $ssd.DrawSymbol($Symbol, $Page, $Column)) { throw "SSD1306 DrawSymbol('$Symbol', page=$Page, col=$Column) failed" } $action = "symbol-$Symbol" } elseif ($Text) { if (-not $ssd.WriteText($Text, $Page, $Align, $FontSize, [bool]$Invert)) { throw "SSD1306 WriteText('$Text', page=$Page, size=$FontSize) failed" } $action = "write-text" } return [PSCustomObject]@{ Module = 'SSD1306' Address = '0x{0:X2}' -f $I2CAddress Action = $action Page = $Page } } |