Find-NetworkDevice.ps1

function Find-NetworkDevice {
    <#
    .SYNOPSIS
        Discovers network devices using ARP, resolving hostnames and MAC vendors.

    .DESCRIPTION
        Scans the local network to find devices by querying the ARP cache.
        Can optionally ping-sweep a subnet to populate the cache first.
        Resolves hostnames via DNS/NetBIOS and looks up MAC vendor information.

    .PARAMETER Subnet
        CIDR notation subnet to scan (e.g., "192.168.1.0/24").
        Defaults to the current network's subnet.

    .PARAMETER IPv6
        Include IPv6 addresses in the results.

    .PARAMETER Force
        Ping-sweep the subnet first to populate the ARP cache.
        Without this, only devices already in the cache are shown.

    .EXAMPLE
        Find-NetworkDevice

        Lists devices currently in the ARP cache on the local subnet.

    .EXAMPLE
        Find-NetworkDevice -Force

        Ping-sweeps the local subnet first, then lists all responding devices.

    .EXAMPLE
        Find-NetworkDevice -Subnet "192.168.1.0/24" -Force

        Scans a specific subnet.

    .EXAMPLE
        Find-NetworkDevice -IPv6

        Includes IPv6 addresses in the output.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Position = 0)]
        [ValidatePattern('^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$')]
        [string]$Subnet,

        [Parameter()]
        [switch]$IPv6,

        [Parameter()]
        [switch]$Force
    )

    # Helper: Get current subnet in CIDR notation
    function Get-CurrentSubnet {
        $adapter = Get-NetIPAddress -AddressFamily IPv4 |
            Where-Object { $_.IPAddress -notlike '127.*' -and $_.PrefixOrigin -ne 'WellKnown' } |
            Sort-Object -Property InterfaceIndex |
            Select-Object -First 1

        if (-not $adapter) {
            throw "Could not determine local network adapter"
        }

        $ip = $adapter.IPAddress
        $prefix = $adapter.PrefixLength
        $ipBytes = [System.Net.IPAddress]::Parse($ip).GetAddressBytes()
        $maskBytes = [byte[]]::new(4)

        for ($i = 0; $i -lt 4; $i++) {
            $bits = [Math]::Min(8, [Math]::Max(0, $prefix - ($i * 8)))
            $maskBytes[$i] = [byte](256 - [Math]::Pow(2, 8 - $bits))
        }

        $networkBytes = for ($i = 0; $i -lt 4; $i++) { $ipBytes[$i] -band $maskBytes[$i] }
        $network = ($networkBytes -join '.')

        return "$network/$prefix"
    }

    # Helper: Get IP range from CIDR
    function Get-IPRange {
        param ([string]$CIDR)

        $parts = $CIDR -split '/'
        $baseIP = [System.Net.IPAddress]::Parse($parts[0])
        $prefix = [int]$parts[1]

        $ipBytes = $baseIP.GetAddressBytes()
        [Array]::Reverse($ipBytes)
        $ipInt = [BitConverter]::ToUInt32($ipBytes, 0)

        $hostBits = 32 - $prefix
        $numHosts = [Math]::Pow(2, $hostBits) - 2
        $networkInt = $ipInt -band ([UInt32]::MaxValue -shl $hostBits)

        $ips = @()
        for ($i = 1; $i -le $numHosts -and $i -le 254; $i++) {
            $currentInt = $networkInt + $i
            $bytes = [BitConverter]::GetBytes([UInt32]$currentInt)
            [Array]::Reverse($bytes)
            $ips += ([System.Net.IPAddress]::new($bytes)).ToString()
        }
        return $ips
    }

    # Helper: Lookup MAC vendor from OUI file
    function Get-MacVendor {
        param ([string]$MAC)

        if (-not $script:OUITable) {
            $ouiPath = Join-Path $PSScriptRoot 'oui.txt'
            if (Test-Path $ouiPath) {
                $script:OUITable = @{}
                Get-Content $ouiPath | ForEach-Object {
                    if ($_ -match '^([0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2})\t(.+)$') {
                        $script:OUITable[$matches[1].ToUpper()] = $matches[2]
                    }
                }
            } else {
                $script:OUITable = @{}
            }
        }

        $prefix = ($MAC -replace '-', ':').ToUpper().Substring(0, 8)
        return $script:OUITable[$prefix]
    }

    # Helper: Resolve hostname
    function Resolve-DeviceHostname {
        param ([string]$IP)

        try {
            $dns = [System.Net.Dns]::GetHostEntry($IP)
            if ($dns.HostName -and $dns.HostName -ne $IP) {
                return $dns.HostName
            }
        } catch { }

        return $null
    }

    # Main logic
    if (-not $Subnet) {
        $Subnet = Get-CurrentSubnet
        Write-Verbose "Detected subnet: $Subnet"
    }

    # Parse subnet for filtering
    $subnetParts = $Subnet -split '/'
    $subnetBase = $subnetParts[0]
    $subnetPrefix = [int]$subnetParts[1]

    # Calculate network address for filtering
    $baseBytes = [System.Net.IPAddress]::Parse($subnetBase).GetAddressBytes()
    $maskBytes = [byte[]]::new(4)
    for ($i = 0; $i -lt 4; $i++) {
        $bits = [Math]::Min(8, [Math]::Max(0, $subnetPrefix - ($i * 8)))
        $maskBytes[$i] = [byte](256 - [Math]::Pow(2, 8 - $bits))
    }

    if ($Force) {
        Write-Verbose "Ping-sweeping subnet $Subnet..."
        $ips = Get-IPRange -CIDR $Subnet

        $jobs = $ips | ForEach-Object {
            Test-Connection -ComputerName $_ -Count 1 -AsJob -ErrorAction SilentlyContinue
        }

        if ($jobs) {
            $null = $jobs | Wait-Job -Timeout 10
            $jobs | Remove-Job -Force
        }

        Start-Sleep -Milliseconds 500
    }

    # Get ARP table
    Write-Verbose "Reading ARP cache..."
    $arpOutput = & arp -a 2>&1

    $devices = @{}
    $currentInterface = $null

    foreach ($line in $arpOutput) {
        if ($line -match 'Interface:\s+(\d+\.\d+\.\d+\.\d+)') {
            $currentInterface = $matches[1]
        }
        elseif ($line -match '^\s*(\d+\.\d+\.\d+\.\d+)\s+([0-9a-f-]{17})\s+(\w+)') {
            $ip = $matches[1]
            $mac = $matches[2].ToUpper() -replace '-', ':'
            $type = $matches[3]

            if ($type -eq 'dynamic' -or $type -eq 'static') {
                # Check if IP is in our subnet
                $ipBytes = [System.Net.IPAddress]::Parse($ip).GetAddressBytes()
                $inSubnet = $true
                for ($i = 0; $i -lt 4; $i++) {
                    if (($ipBytes[$i] -band $maskBytes[$i]) -ne ($baseBytes[$i] -band $maskBytes[$i])) {
                        $inSubnet = $false
                        break
                    }
                }

                if ($inSubnet -and $mac -ne 'FF:FF:FF:FF:FF:FF') {
                    $devices[$mac] = @{
                        IPAddress = $ip
                        MACAddress = $mac
                    }
                }
            }
        }
    }

    # Get IPv6 neighbors if requested
    $ipv6Map = @{}
    if ($IPv6) {
        Write-Verbose "Reading IPv6 neighbor cache..."
        $ipv6Output = & netsh interface ipv6 show neighbors 2>&1

        foreach ($line in $ipv6Output) {
            if ($line -match '^\s*(fe80[^\s]+)\s+([0-9a-f-]{17})') {
                $ip6 = $matches[1]
                $mac6 = $matches[2].ToUpper() -replace '-', ':'
                if (-not $ipv6Map.ContainsKey($mac6)) {
                    $ipv6Map[$mac6] = $ip6
                }
            }
        }
    }

    # Build output objects
    Write-Verbose "Resolving hostnames and vendors..."
    foreach ($mac in $devices.Keys) {
        $device = $devices[$mac]

        [PSCustomObject]@{
            Hostname    = Resolve-DeviceHostname -IP $device.IPAddress
            IPAddress   = $device.IPAddress
            IPv6Address = if ($IPv6) { $ipv6Map[$mac] } else { $null }
            MACAddress  = $device.MACAddress
            Vendor      = Get-MacVendor -MAC $device.MACAddress
        }
    }
}