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, and parses the reply for the device's real AmsNetId, host name and TwinCAT version. 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.
The packet layout documented below was verified against a CX7000 on 2026-09-16.
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.
#
# Packet layout (both directions, verified against a CX7000 on 2026-09-16):
# 0..3 magic 03 66 14 71
# 4..7 reserved (0)
# 8..11 command, LE dword: request 0x00000001, response has 0x80000000 set
# 12..17 AmsNetId of the sender
# 18..19 AmsPort (10000 = 10 27)
# 20..23 block count, followed by {type(2) len(2) payload} blocks
#
# Usage:
# .\ads-discover.ps1 # broadcast from every Preferred IPv4 adapter
# .\ads-discover.ps1 -AdapterName '*Realtek*' # pick by adapter name/description (wildcard OK)
# .\ads-discover.ps1 -LocalIP 169.254.120.42 # 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'
# --- pick source addresses -------------------------------------------------
$sources = @()
if ($LocalIP) {
$sources = @($LocalIP)
}
elseif ($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'." }
$sources = @(Get-NetIPAddress -InterfaceIndex $ad.ifIndex -AddressFamily IPv4 -AddressState Preferred |
Select-Object -First 1 -ExpandProperty IPAddress)
if (-not $sources) { throw "Adapter '$($ad.Name)' has no Preferred IPv4 address." }
}
else {
# No hint given: broadcast out of every usable adapter instead of guessing one.
$sources = @(Get-NetIPAddress -AddressFamily IPv4 -AddressState Preferred -ErrorAction SilentlyContinue |
Where-Object {
$_.IPAddress -ne '127.0.0.1' -and
(Get-NetAdapter -InterfaceIndex $_.InterfaceIndex -ErrorAction SilentlyContinue).Status -eq 'Up'
} | Select-Object -ExpandProperty IPAddress)
if (-not $sources) { throw "No active IPv4 adapter found." }
}
# --- build request ---------------------------------------------------------
function New-DiscoveryPacket([string]$ip) {
# Sender AmsNetId = local IP + .1.1 (Beckhoff convention for a host without a route)
$b = [byte[]]::new(24)
$b[0]=0x03; $b[1]=0x66; $b[2]=0x14; $b[3]=0x71 # magic
$b[8]=0x01 # command 0x00000001 = discover
$nid = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
[Array]::Copy($nid, 0, $b, 12, 4); $b[16]=1; $b[17]=1 # sender AmsNetId
$b[18]=0x10; $b[19]=0x27 # sender AmsPort 10000
# 20..23 = block count 0
return $b
}
# --- send + listen ---------------------------------------------------------
$socks = @()
$results = [ordered]@{}
try {
foreach ($src in $sources) {
try {
$u = New-Object System.Net.Sockets.UdpClient
$u.Client.SetSocketOption(
[System.Net.Sockets.SocketOptionLevel]::Socket,
[System.Net.Sockets.SocketOptionName]::ReuseAddress, $true)
$u.Client.Bind([System.Net.IPEndPoint]::new([System.Net.IPAddress]::Parse($src), 0))
$u.EnableBroadcast = $true
$pkt = New-DiscoveryPacket $src
[void]$u.Send($pkt, $pkt.Length,
[System.Net.IPEndPoint]::new([System.Net.IPAddress]::Broadcast, $DiscoveryPort))
Write-Host ("[+] Broadcast from {0} -> 255.255.255.255:{1} ({2} bytes)" -f $src, $DiscoveryPort, $pkt.Length)
$socks += $u
} catch {
Write-Host ("[!] {0}: {1}" -f $src, $_.Exception.Message)
if ($u) { $u.Close() }
}
}
if ($socks.Count -eq 0) { throw "No socket could be bound." }
Write-Host ("[*] Listening for {0} ms..." -f $TimeoutMs)
$sw = [System.Diagnostics.Stopwatch]::StartNew()
while ($sw.ElapsedMilliseconds -lt $TimeoutMs) {
$busy = $false
foreach ($u in $socks) {
while ($u.Available -gt 0) {
$busy = $true
$remote = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0)
$data = $u.Receive([ref]$remote)
if ($data.Length -lt 24) { continue }
if ($data[0] -ne 0x03 -or $data[1] -ne 0x66 -or $data[2] -ne 0x14 -or $data[3] -ne 0x71) { continue }
$cmd = [BitConverter]::ToUInt32($data, 8)
if (-not ($cmd -band 0x80000000)) { continue } # not a response
$nid = ($data[12..17]) -join '.'
$port = [BitConverter]::ToUInt16($data, 18)
# optional info blocks
$name = ''; $tcVer = ''
$n = [BitConverter]::ToUInt32($data, 20)
$off = 24
for ($i = 0; $i -lt $n -and ($off + 4) -le $data.Length; $i++) {
$type = [BitConverter]::ToUInt16($data, $off)
$len = [BitConverter]::ToUInt16($data, $off + 2)
$off += 4
if ($len -eq 0 -or ($off + $len) -gt $data.Length) { break }
$pl = $data[$off..($off + $len - 1)]
switch ($type) {
5 { $name = [System.Text.Encoding]::ASCII.GetString($pl).Trim([char]0) }
3 { if ($len -ge 4) { $tcVer = "{0}.{1}.{2}" -f $pl[0], $pl[1], [BitConverter]::ToUInt16($pl, 2) } }
}
$off += $len
}
$results[$nid] = [PSCustomObject]@{
IP = $remote.Address.ToString()
Name = $name
AmsNetId = $nid
Port = $port
TwinCAT = $tcVer
Bytes = $data.Length
Hex = ($data | ForEach-Object { $_.ToString('X2') }) -join ' '
}
}
}
if (-not $busy) { Start-Sleep -Milliseconds 50 }
}
}
finally {
foreach ($u in $socks) { $u.Close() }
}
# --- report ----------------------------------------------------------------
$found = @($results.Values)
if ($found.Count -eq 0) {
Write-Host "[-] No responses."
Write-Host " Check: adapter link, Windows Firewall, PLC boot state."
} else {
if ($ShowHex) {
$found | Format-Table IP, Name, AmsNetId, Port, TwinCAT, Bytes, Hex -AutoSize -Wrap
} else {
$found | Format-Table IP, Name, AmsNetId, Port, TwinCAT, Bytes -AutoSize
}
Write-Host ("[+] {0} device(s) found." -f $found.Count)
}
Usage
# 1. Broadcast from every active IPv4 adapter (no need to guess which NIC faces the PLC)
.\ads-discover.ps1
# 2. Restrict to one adapter (matches Name or InterfaceDescription, wildcards OK)
.\ads-discover.ps1 -AdapterName '*Realtek*PCIe*GbE*'
# 3. Pin to a source IP (only valid if that IP is currently assigned to an Up adapter)
.\ads-discover.ps1 -LocalIP 169.254.120.42
# 4. Listen longer (PLC just powered on, or many devices present)
.\ads-discover.ps1 -TimeoutMs 8000
# 5. Show full response hex (debugging / decoding unknown info blocks)
.\ads-discover.ps1 -ShowHex
If ExecutionPolicy blocks it:
powershell -ExecutionPolicy Bypass -File .\ads-discover.ps1
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
-LocalIP | string | — | Send only from this local IP. Highest priority — overrides -AdapterName. |
-AdapterName | string | — | Matches Name or InterfaceDescription; wildcards supported. Uses the adapter's first Preferred IPv4 address. |
-TimeoutMs | int | 4000 | Total listen window after the broadcasts are sent (milliseconds). |
-DiscoveryPort | int | 48899 | ADS discovery UDP port. Don't change unless you have a reason. |
-ShowHex | switch | off | Also print the full response bytes (hex) for each device. |
With neither -LocalIP nor -AdapterName, the script binds one socket per active adapter and broadcasts from all of them, then polls every socket during the listen window. A multi-NIC host therefore does not need to be told which port the PLC is on. A bind failure on one adapter is reported as [!] <ip>: <message> and the remaining adapters continue.
Output
A real run on a four-adapter host (Ethernet to the PLC, Wi-Fi, Tailscale, second Ethernet):
PS> .\ads-discover.ps1
[+] Broadcast from 169.254.130.151 -> 255.255.255.255:48899 (24 bytes)
[+] Broadcast from 192.168.1.78 -> 255.255.255.255:48899 (24 bytes)
[+] Broadcast from 100.94.22.117 -> 255.255.255.255:48899 (24 bytes)
[+] Broadcast from 169.254.120.42 -> 255.255.255.255:48899 (24 bytes)
[*] Listening for 4000 ms...
IP Name AmsNetId Port TwinCAT Bytes
-- ---- -------- ---- ------- -----
169.254.180.150 CX-71ECA3 5.98.91.39.1.1 10000 3.1.4024 395
192.168.1.195 LAPTOP-MEEU753P 192.168.13.1.1.1 10000 3.1.4024 401
[+] 2 device(s) found.
| Column | Meaning |
|---|---|
IP | UDP source IP of the response packet — the responder's IP. |
Name | Device host name, from info block type 0x0005. Empty if the device does not send one. |
AmsNetId | The responder's real AmsNetId, from response bytes 12..17. This is the value to enter when adding a TwinCAT route. |
Port | Sender AmsPort in the response, normally 10000. |
TwinCAT | TwinCAT version major.minor.build, from info block type 0x0003. Empty if absent. |
Bytes | Response packet length. Varies with how many info blocks the device appends — roughly 400 bytes for a TC3 responder. |
Two things worth reading off that output:
- Not every responder is a PLC. Anything running a TwinCAT Router answers, including engineering PCs —
LAPTOP-MEEU753Pabove is another Windows machine on the office LAN, not a device to route to. Pick the target byName/ IP subnet, not by "the first row". - The AmsNetId is not derived from the responder's IP.
CX-71ECA3answers from169.254.180.150but reports5.98.91.39.1.1— a CX gets its NetId from its MAC/serial, not its address. Guessing "IP + .1.1" for a PLC is wrong; that convention only applies to the sender NetId this script makes up for itself.
Note also that the PLC replied from 169.254.180.150 while the host's own APIPA addresses were 169.254.130.151 and 169.254.120.42. Link-local addresses are picked independently at both ends, so they share the /16 but nothing else — this is normal and does not mean the subnet is misconfigured.
Results are keyed by AmsNetId, so a device that answers on more than one adapter appears once.
Typical workflow (find PLC → add TwinCAT route)
1. Plug in network cable, power on PLC
2. .\ads-discover.ps1
→ Pick the PLC row: IP 169.254.180.150, AmsNetId 5.98.91.39.1.1
3. ping 169.254.180.150 # 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.180.150
- AmsNetId: 5.98.91.39.1.1 (paste from the script, or press Address Info to re-query it live)
- Transport: TCP/IP
- Remote User / Password: default for new CX series is Administrator / 1
Press Add Route
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
[!] <ip>: 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) | Drop -LocalIP and let it broadcast from every adapter, or use -AdapterName '*Realtek*' |
No socket could be bound. | Every candidate source address failed to bind (all stale, or the address is gone) | Check what is actually assigned with Get-NetIPAddress -AddressFamily IPv4 |
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 Name / TwinCAT blank | Device sent no info blocks, or used block types this script doesn't decode | Re-run with -ShowHex and decode the tail manually |
| Device found but TwinCAT still won't connect | Route not added yet, or the PLC rejects the credentials | Follow the "typical workflow" above to manually Add Route |
How it works
Request — a 24-byte UDP broadcast to 255.255.255.255:48899:
offset 0..3 : 03 66 14 71 magic
offset 4..7 : 00 00 00 00 reserved
offset 8..11 : 01 00 00 00 command, LE dword: 0x00000001 = discover
offset 12..17 : <sender AmsNetId> 6 bytes; this script uses "local IPv4 + 1.1"
offset 18..19 : 10 27 sender AmsPort LE, 0x2710 = 10000
offset 20..23 : 00 00 00 00 block count = 0
Response — same 24-byte header, then a variable-length tail:
offset 8..11 : command with 0x80000000 set response flag
offset 12..17 : the device's real AmsNetId
offset 18..19 : the device's AmsPort (10000)
offset 20..23 : block count N
offset 24.. : N blocks of { type(2) len(2) payload(len) }
Info blocks the script decodes:
| Type | Content |
|---|---|
0x0003 | TwinCAT version — payload[0] major, payload[1] minor, payload[2..3] build (LE uint16) |
0x0005 | Device host name, ASCII, NUL-padded |
Unknown block types are skipped by their length field, so an unexpected tail does not break parsing; a block whose length would run past the end of the packet stops the loop.
A received packet is discarded unless it is at least 24 bytes, carries the magic, and has the response bit 0x80000000 set in the command dword. That last check is what filters out the host's own broadcast when it comes back on the sending socket.
The protocol is not officially documented; the layout comes from community reverse engineering (pyads, ads-discover, and other open-source tools) plus the CX7000 capture noted above. It may change across TwinCAT versions; currently works on both TC2 and TC3.