Private/Ftdi.Unix.ps1

# Ftdi.Unix.ps1
# Unix-specific FTDI implementation (Linux/macOS)

function Invoke-FtdiUnixEnumerate {
    [CmdletBinding()]
    [OutputType([System.Object[]])]
    param()

    # FTDI USB Product ID -> chip type name
    $ftdiPidMap = @{
        '6001' = 'FT232R'
        '6010' = 'FT2232H'
        '6011' = 'FT4232H'
        '6014' = 'FT232H'
        '6015' = 'FT-X Series'
        '0600' = 'FT232BM'
        '0601' = 'FT245BM'
    }

    $sysDevicesPath = '/sys/bus/usb/devices'

    # If sysfs is not available (non-Linux Unix / container), fall back to stubs.
    if (-not (Test-Path $sysDevicesPath)) {
        Write-Verbose "sysfs not available; returning Unix stub devices"
        return Invoke-FtdiUnixStubs
    }

    try {
        $found = @()

        # Use [System.IO.Directory]::GetDirectories() for sysfs traversal at every level.
        # Get-ChildItem on Linux sysfs (a virtual pseudo-filesystem) can return DirectoryInfo
        # objects with null FullName/Name properties for certain kernel-internal nodes.
        # Calling any method on those null-property objects throws 'You cannot call a method
        # on a null-valued expression'. Directory.GetDirectories() returns plain string[]
        # (absolute path strings), which are never null, avoiding the problem entirely.
        foreach ($devDir in [System.IO.Directory]::GetDirectories($sysDevicesPath)) {
            try {
                $vendorFile = Join-Path $devDir 'idVendor'

                # Only process FTDI devices (VID 0403)
                if (-not ([System.IO.File]::Exists($vendorFile))) { continue }

                # Use [System.IO.File]::ReadAllText() for all sysfs attribute reads.
                # Get-Content on Linux sysfs can produce null or behave unexpectedly even
                # with -ErrorAction SilentlyContinue; File.ReadAllText() returns a plain
                # string (never null) and throws IOException on failure, which is caught by
                # the per-device try/catch below.
                $readSysfs = {
                    param([string]$path)
                    if ([System.IO.File]::Exists($path)) {
                        try { return [System.IO.File]::ReadAllText($path).Trim() } catch { return '' }
                    }
                    return ''
                }

                $vid     = & $readSysfs $vendorFile
                if ($vid -ne '0403') { continue }

                $pid     = & $readSysfs (Join-Path $devDir 'idProduct')
                $serial  = & $readSysfs (Join-Path $devDir 'serial')
                $product = & $readSysfs (Join-Path $devDir 'product')
                $busNum  = & $readSysfs (Join-Path $devDir 'busnum')
                $devNum  = & $readSysfs (Join-Path $devDir 'devnum')

                # Find associated /dev/ttyUSBx.
                # USB sysfs layout: <devDir>/<devBase>:1.0/ttyUSB0
                # e.g. /sys/bus/usb/devices/1-2/1-2:1.0/ttyUSB0
                #
                # Use [System.IO.Directory]::GetDirectories() rather than Get-ChildItem.
                # PowerShell's Get-ChildItem produces DirectoryInfo objects backed by
                # sysfs virtual nodes; some of those nodes expose null property values
                # (Name, FullName) that crash any .Trim() / string interpolation downstream.
                # .NET Directory methods return plain strings (paths) which are never null.
                $isVcp      = $false
                $locationId = "usb-bus$busNum-dev$devNum"
                try {
                    $devBaseName = [System.IO.Path]::GetFileName($devDir)   # e.g. "1-2"
                    foreach ($ifPath in [System.IO.Directory]::GetDirectories($devDir, "${devBaseName}:*")) {
                        foreach ($ttyPath in [System.IO.Directory]::GetDirectories($ifPath, 'ttyUSB*')) {
                            $locationId = '/dev/' + [System.IO.Path]::GetFileName($ttyPath)
                            $isVcp      = $true
                            break
                        }
                        if ($isVcp) { break }
                    }
                } catch {
                    # ttyUSB probe failed for this device; treat as non-VCP (safe default)
                    Write-Verbose " sysfs: ttyUSB probe failed for '${devDir}': $($_.Exception.Message)"
                }

                # If the kernel ftdi_sio (VCP) driver claimed the device, a ttyUSBx will exist.
                # D2XX / libftdi requires that driver to be unbound first.

                $typeName = if ($ftdiPidMap.ContainsKey($pid)) { $ftdiPidMap[$pid] } else { "FTDI-$pid" }
                $caps     = Get-FtdiChipCapabilities -TypeName $typeName
                # DeviceId format matches IoT/Windows output: 0x + 4-char VID + 8-char device ID
                # e.g. VID=0403 PID=6014 -> 0x040300006014
                $deviceId = '0x{0}0000{1}' -f $vid.ToUpper(), $pid.ToUpper()

                $found += [PSCustomObject]@{
                    Index          = $found.Count
                    Type           = $typeName
                    Description    = if ($product) { $product } else { "FTDI $typeName" }
                    SerialNumber   = if ($serial)  { $serial }  else { '' }
                    LocationId     = $locationId
                    IsOpen         = $false
                    Flags          = '0x00000000'
                    DeviceId       = $deviceId
                    Handle         = $null
                    Driver         = if ($isVcp) { 'ftdi_sio (VCP)' } else { 'sysfs' }
                    Platform       = 'Unix'
                    IsVcp          = $isVcp
                    GpioMethod     = $caps.GpioMethod
                    GpioPins       = $caps.GpioPins
                    HasMpsse       = $caps.HasMpsse
                    CapabilityNote = $caps.CapabilityNote
                }
            } catch {
                # Include ScriptStackTrace so the exact failing line is visible in -Verbose output.
                Write-Verbose " sysfs: skipped device '$devDir': $($_.Exception.Message)"
                Write-Verbose " at: $($_.ScriptStackTrace -replace '\n','; ')"
            }
        }

        if ($found.Count -eq 0) {
            Write-Verbose "No FTDI devices found via sysfs; returning Unix stub devices"
            return Invoke-FtdiUnixStubs
        }

        return $found

    } catch {
        Write-Warning "Unix sysfs enumeration failed: $($_.Exception.Message)"
        return Invoke-FtdiUnixStubs
    }
}

function Invoke-FtdiUnixStubs {
    # Returns hardcoded stub device objects for dev/CI environments with no hardware.
    $caps232H = Get-FtdiChipCapabilities -TypeName 'FT232H'
    $caps232R = Get-FtdiChipCapabilities -TypeName 'FT232R'
    return @(
        [PSCustomObject]@{
            Index          = 0
            Type           = 'FT232H'
            Description    = 'FT232H USB-Serial (Unix STUB)'
            SerialNumber   = 'UNIXSTUB001'
            LocationId     = '/dev/ttyUSB0'
            IsOpen         = $false
            Flags          = '0x00000000'
            DeviceId       = '0x040300006014'
            Handle         = $null
            Driver         = 'libftdi (STUB)'
            Platform       = 'Unix'
            IsVcp          = $false
            GpioMethod     = $caps232H.GpioMethod
            GpioPins       = $caps232H.GpioPins
            HasMpsse       = $caps232H.HasMpsse
            CapabilityNote = $caps232H.CapabilityNote
        },
        [PSCustomObject]@{
            Index          = 1
            Type           = 'FT232R'
            Description    = 'FT232R USB UART (Unix STUB)'
            SerialNumber   = 'UNIXSTUB002'
            LocationId     = '/dev/ttyUSB1'
            IsOpen         = $false
            Flags          = '0x00000000'
            DeviceId       = '0x040300006001'
            Handle         = $null
            Driver         = 'libftdi (STUB)'
            Platform       = 'Unix'
            IsVcp          = $false
            GpioMethod     = $caps232R.GpioMethod
            GpioPins       = $caps232R.GpioPins
            HasMpsse       = $caps232R.HasMpsse
            CapabilityNote = $caps232R.CapabilityNote
        }
    )
}

function Invoke-FtdiUnixOpen {
    [CmdletBinding()]
    [OutputType([System.Object])]
    param(
        [Parameter(Mandatory = $true)]
        [int]$Index
    )

    # Get device info from sysfs enumeration for metadata
    $devices     = Invoke-FtdiUnixEnumerate
    $targetDevice = if ($Index -lt $devices.Count) { $devices[$Index] } else { $null }

    $serial  = if ($targetDevice) { $targetDevice.SerialNumber } else { "DEV$Index" }
    $desc    = if ($targetDevice) { $targetDevice.Description  } else { "Unknown FTDI Device" }
    $type    = if ($targetDevice) { $targetDevice.Type         } else { 'FT232H' }
    $locId   = if ($targetDevice) { $targetDevice.LocationId   } else { "usb-bus?-dev?" }
    $caps    = Get-FtdiChipCapabilities -TypeName $type

    # ---------------------------------------------------------------------------
    # Path A: native P/Invoke (libftd2xx.so loaded) - real hardware handle
    # ---------------------------------------------------------------------------
    if ($script:FtdiNativeAvailable) {
        try {
            Write-Verbose "Invoke-FtdiUnixOpen: opening device $Index via native P/Invoke (FT_Open)"
            $nativeHandle = Invoke-FtdiNativeOpen -Index $Index

            $conn = [PSCustomObject]@{
                Device         = $null          # No FTD2XX_NET.FTDI object on Linux
                NativeHandle   = $nativeHandle  # IntPtr from FT_Open
                Index          = $Index
                SerialNumber   = $serial
                Description    = $desc
                Type           = $type
                LocationId     = $locId
                IsOpen         = $true
                GpioMethod     = $caps.GpioMethod
                GpioPins       = $caps.GpioPins
                HasMpsse       = $caps.HasMpsse
                MpsseEnabled   = $caps.HasMpsse
                CapabilityNote = $caps.CapabilityNote
                Platform       = 'Unix'
            }

            $conn | Add-Member -MemberType ScriptMethod -Name 'Close' -Value {
                if ($this.IsOpen -and $this.NativeHandle -ne [IntPtr]::Zero) {
                    Invoke-FtdiNativeClose -Handle $this.NativeHandle
                }
                $this.IsOpen     = $false
                $this.NativeHandle = [IntPtr]::Zero
            } | Out-Null

            Write-Verbose "Invoke-FtdiUnixOpen: device $Index opened via native D2XX handle"
            return $conn

        } catch {
            Write-Verbose "Invoke-FtdiUnixOpen: native open failed ($($_.Exception.Message)); falling back to stub"
        }
    }

    # ---------------------------------------------------------------------------
    # Path B: stub (native lib not loaded or open failed)
    # ---------------------------------------------------------------------------
    Write-Verbose "Creating stub connection for device $Index (Unix)"

    return [PSCustomObject]@{
        Device         = $null
        NativeHandle   = [IntPtr]::Zero
        Index          = $Index
        SerialNumber   = $serial
        Description    = $desc
        Type           = $type
        LocationId     = $locId
        IsOpen         = $true
        GpioMethod     = $caps.GpioMethod
        GpioPins       = $caps.GpioPins
        HasMpsse       = $caps.HasMpsse
        MpsseEnabled   = $caps.HasMpsse
        CapabilityNote = $caps.CapabilityNote
        Platform       = 'Unix (STUB)'
    } | Add-Member -MemberType ScriptMethod -Name 'Close' -Value {
        $this.IsOpen = $false
    } -PassThru
}

function Invoke-FtdiUnixClose {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Object]$Connection
    )

    try {
        if ($null -ne $Connection -and $Connection.PSObject.Methods['Close']) {
            $Connection.Close()
        }
    } catch {
        Write-Warning "Invoke-FtdiUnixClose: $_"
    }
}