---
title: ads-discover.ps1 — Beckhoff ADS broadcast discovery
title_en: ads-discover.ps1 — Beckhoff ADS broadcast discovery
description: A PowerShell script that sends an ADS discovery broadcast (UDP 48899) to find TwinCAT devices on the same subnet. No TwinCAT install or Npcap required — useful as a standalone verifier when Broadcast Search fails.
sidebar_label: ADS broadcast discovery tool
---

# ads-discover.ps1 — Beckhoff ADS broadcast discovery tool

Sends an ADS discovery broadcast (UDP 48899) directly from PowerShell to find Beckhoff TwinCAT devices on the same subnet. No TwinCAT installation required, and no Npcap dependency. Useful as a standalone verifier when TwinCAT Broadcast Search fails.

## Prerequisites

- Windows + PowerShell 5.1 or 7+
- Host and PLC on the same L2 segment (direct cable or same switch)
- PLC powered on, network port LEDs lit
- Local NIC has an IPv4 address (static IP or APIPA `169.254.x.x` both work)

The script itself does not require administrator privileges.

## Script

Save the following as `ads-discover.ps1`:

```powershell
# ADS Broadcast Discovery for Beckhoff TwinCAT devices
# Sends UDP discovery packet to 255.255.255.255:48899 and listens for responses.
#
# Usage:
#   .\ads-discover.ps1                                       # auto-detect adapter
#   .\ads-discover.ps1 -AdapterName '*Realtek*'              # pick by adapter description (wildcard OK)
#   .\ads-discover.ps1 -LocalIP 169.254.188.1                # pin to specific IP
#   .\ads-discover.ps1 -TimeoutMs 8000                       # listen longer
#   .\ads-discover.ps1 -ShowHex                              # dump response bytes

param(
    [string]$LocalIP,
    [string]$AdapterName,
    [int]$TimeoutMs = 4000,
    [int]$DiscoveryPort = 48899,
    [switch]$ShowHex
)

$ErrorActionPreference = 'Stop'

# Resolve LocalIP from adapter name if given (more stable than IP — APIPA can change)
if (-not $LocalIP -and $AdapterName) {
    $ad = Get-NetAdapter | Where-Object {
        $_.Status -eq 'Up' -and ($_.Name -like $AdapterName -or $_.InterfaceDescription -like $AdapterName)
    } | Select-Object -First 1
    if (-not $ad) { throw "No Up adapter matches '$AdapterName'." }
    $LocalIP = (Get-NetIPAddress -InterfaceIndex $ad.ifIndex -AddressFamily IPv4 -AddressState Preferred).IPAddress
}

# Full auto-detect: first Up IPv4 adapter, non-loopback
if (-not $LocalIP) {
    $cand = Get-NetIPAddress -AddressFamily IPv4 -AddressState Preferred -ErrorAction SilentlyContinue |
        Where-Object { $_.IPAddress -ne '127.0.0.1' -and (Get-NetAdapter -InterfaceIndex $_.InterfaceIndex).Status -eq 'Up' } |
        Select-Object -First 1
    if (-not $cand) { throw "No active IPv4 adapter found." }
    $LocalIP = $cand.IPAddress
}
Write-Host "[*] Using local IP: $LocalIP"

# Sender AmsNetId = local IP + .1.1 (Beckhoff convention for unroutted senders)
$ipBytes    = ([System.Net.IPAddress]::Parse($LocalIP)).GetAddressBytes()
$netId      = [byte[]]($ipBytes + @(1,1))
$senderPort = 10000

# 24-byte ADS discovery request
$packet = [byte[]]::new(24)
$packet[0]=0x03; $packet[1]=0x66; $packet[2]=0x14; $packet[3]=0x71   # magic
# bytes 4..7 = request type 0x00000000 (discover)
for ($i=0; $i -lt 6; $i++) { $packet[8+$i] = $netId[$i] }
$packet[14] = [byte]( $senderPort       -band 0xFF)
$packet[15] = [byte](($senderPort -shr 8) -band 0xFF)
$packet[16] = 1

# UDP socket bound to chosen adapter
$udp = New-Object System.Net.Sockets.UdpClient
$udp.Client.SetSocketOption(
    [System.Net.Sockets.SocketOptionLevel]::Socket,
    [System.Net.Sockets.SocketOptionName]::ReuseAddress, $true)
$udp.Client.Bind([System.Net.IPEndPoint]::new([System.Net.IPAddress]::Parse($LocalIP), 0))
$udp.EnableBroadcast = $true

$dest = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Broadcast, $DiscoveryPort)
[void]$udp.Send($packet, $packet.Length, $dest)
Write-Host ("[+] Broadcast sent to 255.255.255.255:{0} ({1} bytes)" -f $DiscoveryPort, $packet.Length)
Write-Host ("[*] Listening for {0} ms..." -f $TimeoutMs)

$udp.Client.ReceiveTimeout = $TimeoutMs
$results = @()
try {
    while ($true) {
        $remote = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0)
        $data   = $udp.Receive([ref]$remote)
        $nid    = if ($data.Length -ge 14) { ($data[8..13]) -join '.' } else { '?' }
        $hex    = ($data | ForEach-Object { $_.ToString('X2') }) -join ' '
        $results += [PSCustomObject]@{
            IP       = $remote.Address.ToString()
            AmsNetId = $nid
            Bytes    = $data.Length
            Hex      = $hex
        }
    }
} catch {
    # ReceiveTimeout -> normal exit
}
$udp.Close()

if ($results.Count -eq 0) {
    Write-Host "[-] No responses."
    Write-Host "    Check: adapter link, Windows Firewall, PLC boot state."
} else {
    if ($ShowHex) {
        $results | Format-Table IP, AmsNetId, Bytes, Hex -AutoSize -Wrap
    } else {
        $results | Format-Table IP, AmsNetId, Bytes -AutoSize
    }
    Write-Host ("[+] {0} device(s) found." -f $results.Count)
}
```

## Usage

```powershell
# 1. Auto-pick the first Up adapter
.\ads-discover.ps1

# 2. Specify adapter (recommended — more stable than IP, since APIPA drifts)
.\ads-discover.ps1 -AdapterName '*Realtek*PCIe*GbE*'

# 3. Specify source IP (only valid if that IP is currently assigned to an Up adapter)
.\ads-discover.ps1 -LocalIP 169.254.188.1

# 4. Listen longer (PLC just powered on, or many devices present)
.\ads-discover.ps1 -AdapterName '*Realtek*' -TimeoutMs 8000

# 5. Show full response hex (debugging)
.\ads-discover.ps1 -AdapterName '*Realtek*' -ShowHex
```

If ExecutionPolicy blocks it:

```powershell
powershell -ExecutionPolicy Bypass -File .\ads-discover.ps1 -AdapterName '*Realtek*'
```

## Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `-LocalIP` | string | — | The local IP to send the broadcast from. If omitted, falls back to `-AdapterName` or auto-detect. |
| `-AdapterName` | string | — | Matches `Name` or `InterfaceDescription`; wildcards supported. **The most stable way to specify the adapter.** |
| `-TimeoutMs` | int | 4000 | Receive window after sending (milliseconds). |
| `-DiscoveryPort` | int | 48899 | ADS discovery UDP port. Don't change unless you have a reason. |
| `-ShowHex` | switch | off | Print the full response bytes (hex) for each device. |

## Output columns

```
IP             AmsNetId              Bytes
--             --------              -----
169.254.188.13 169.254.188.129.5.124 24
```

| Column | Meaning |
|---|---|
| `IP` | **Reliable.** The UDP source IP of the response packet — this is the PLC's IP. |
| `AmsNetId` | **For reference only, don't trust it.** This is bytes 8..13 of the response. In practice it varies with the NetId the local host sent out — it is **not** the PLC's real AmsNetId. |
| `Bytes` | Response packet length. Usually ≥ 24. |

> **About AmsNetId**: this script does not parse the full ADS discovery TLV section. To get the PLC's real AmsNetId, use TwinCAT Router's **Add Route Dialog**, enter the IP, and press **Address Info** to query the PLC live.

## Typical workflow (find PLC → add TwinCAT route)

```
1. Plug in network cable, power on PLC
2. .\ads-discover.ps1 -AdapterName '*Realtek*'
     → Get the PLC IP (e.g. 169.254.188.13)
3. ping 169.254.188.13            # confirm L3 reachability
4. Test-NetConnection ... -Port 48898   # confirm ADS TCP reachability
5. Open TwinCAT tray icon → Router → Edit Routes → Add
     - Advanced Settings
     - IP Address: 169.254.188.13
     - Press Address Info → AmsNetId is filled in automatically
     - Transport: TCP/IP
     - Remote User / Password: default for new CX series is Administrator / 1
   Press Add Route
```

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| Bind failure: `The requested address is not valid in its context` | The IP passed via `-LocalIP` is no longer the adapter's current IP (APIPA re-assigned) | Use `-AdapterName '*Realtek*'`, or check current IP with `Get-NetIPAddress -InterfaceIndex <idx>` |
| `No responses` but PLC LEDs are normal | 1. Windows Firewall blocking the UDP 48899 response<br/>2. Different VLAN / L3 segment | 1. Temporarily disable firewall to test, or allow PowerShell through<br/>2. Verify both ends are on the same subnet |
| `No responses`, PLC just powered on | CX series cold boot takes 30 sec to 2 min | Wait for LEDs to stabilize and run again, or use `-TimeoutMs 10000` |
| Device found but TwinCAT still won't connect | TwinCAT Router AmsNetId mismatch, or route not yet added | Follow the "typical workflow" above to manually Add Route |
| Multiple NICs, auto-detect picks the wrong one | Auto-detect only takes the first Up adapter | Explicitly specify `-AdapterName` |

## How it works (briefly)

The ADS discovery packet is a 24-byte UDP broadcast:

```
offset 0..3   : 03 66 14 71            magic
offset 4..7   : 00 00 00 00            request type = discover
offset 8..13  : <sender AmsNetId>      6 bytes; this script uses "local IP + 1.1"
offset 14..15 : <sender port LE>       0x2710 = 10000
offset 16     : 01                     option flag
offset 17..23 : 00                     reserved
```

Sent to `255.255.255.255:48899`. Beckhoff devices on the same subnet reply via UDP to the source IP:port. This script treats the UDP source IP as the PLC IP (reliable) and shows response bytes 8..13 (for observation only, not the real AmsNetId).

The protocol is not officially documented; the format comes from community reverse engineering (pyads, ads-discover, and other open-source tools). It may change across TwinCAT versions; currently works on both TC2 and TC3.
