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.xboth work)
The script itself does not require administrator privileges.
Script
Save the following as ads-discover.ps1:
# 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
# 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 -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 2. Different VLAN / L3 segment | 1. Temporarily disable firewall to test, or allow PowerShell through 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.