Skip to main content

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:

# 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

ParameterTypeDefaultDescription
-LocalIPstringThe local IP to send the broadcast from. If omitted, falls back to -AdapterName or auto-detect.
-AdapterNamestringMatches Name or InterfaceDescription; wildcards supported. The most stable way to specify the adapter.
-TimeoutMsint4000Receive window after sending (milliseconds).
-DiscoveryPortint48899ADS discovery UDP port. Don't change unless you have a reason.
-ShowHexswitchoffPrint the full response bytes (hex) for each device.

Output columns

IP AmsNetId Bytes
-- -------- -----
169.254.188.13 169.254.188.129.5.124 24
ColumnMeaning
IPReliable. The UDP source IP of the response packet — this is the PLC's IP.
AmsNetIdFor 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.
BytesResponse 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

SymptomLikely causeFix
Bind failure: The requested address is not valid in its contextThe 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 normal1. 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 onCX series cold boot takes 30 sec to 2 minWait for LEDs to stabilize and run again, or use -TimeoutMs 10000
Device found but TwinCAT still won't connectTwinCAT Router AmsNetId mismatch, or route not yet addedFollow the "typical workflow" above to manually Add Route
Multiple NICs, auto-detect picks the wrong oneAuto-detect only takes the first Up adapterExplicitly 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.