Flow Scan
Finds FANUC controls on this network and shows them live. Read-only: nothing is written to a machine, nothing leaves this computer.
# Flow Scan - finds FANUC controls on the local network and shows them live in the browser. # READ-ONLY: sends only read requests (model, state, program number, program comment, # parts counter, tool, spindle speed and load, axis load, overrides, program line, alarms). # Nothing is written to a machine. Listens on 127.0.0.1 only; nothing leaves this PC. # Works in Windows PowerShell 5.1 with no extra software and no administrator rights. param([int]$Port = 8377, [switch]$NoBrowser, [int]$Seconds = 0) $ErrorActionPreference = 'Stop' $FocasPort = 8193 function BE16([int]$v) { return [byte[]]((($v -shr 8) -band 0xFF), ($v -band 0xFF)) } function BE32([int]$v) { $b = [BitConverter]::GetBytes([int32]$v); [Array]::Reverse($b); return [byte[]]$b } function RdU16([byte[]]$b, [int]$o) { return (([int]$b[$o]) -shl 8) -bor ([int]$b[$o + 1]) } function RdI32([byte[]]$b, [int]$o) { $t = [byte[]]($b[$o + 3], $b[$o + 2], $b[$o + 1], $b[$o]); return [BitConverter]::ToInt32($t, 0) } function RdText([byte[]]$b, [int]$o, [int]$n) { $end = $o while ($end -lt ($o + $n) -and $b[$end] -ne 0) { $end++ } return [Text.Encoding]::ASCII.GetString($b, $o, $end - $o).Trim() } function Read-Exact($stream, [int]$n) { $buf = New-Object byte[] $n $got = 0 while ($got -lt $n) { $r = $stream.Read($buf, $got, $n - $got) if ($r -le 0) { throw 'connection closed' } $got += $r } return , $buf } function Send-Frame($stream, [int]$ftype, [byte[]]$payload) { $out = New-Object IO.MemoryStream $head = [byte[]](0xA0, 0xA0, 0xA0, 0xA0) $out.Write($head, 0, 4) foreach ($part in @((BE16 1), (BE16 $ftype), (BE16 $payload.Length), $payload)) { if ($part.Length -gt 0) { $out.Write([byte[]]$part, 0, $part.Length) } } $bytes = $out.ToArray() $stream.Write($bytes, 0, $bytes.Length) } function Recv-Frame($stream) { [byte[]]$h = Read-Exact $stream 10 if ($h[0] -ne 0xA0 -or $h[1] -ne 0xA0 -or $h[2] -ne 0xA0 -or $h[3] -ne 0xA0) { throw 'not a FOCAS answer' } $len = RdU16 $h 8 [byte[]]$data = @() if ($len -gt 0) { $data = Read-Exact $stream $len } return @{ ftype = (RdU16 $h 6); data = $data } } # One read request. Returns the data bytes, or $null when the control refuses this request. # $cls 1 = CNC, 2 = PMC (then $code 0x8001 reads a PMC range, $v1..$v2 = addresses). function Focas-Read($conn, [int]$code, [int]$v1 = 0, [int]$v2 = 0, [int]$v3 = 0, [int]$path = 1, [int]$v4 = 0, [int]$cls = 1) { $cmd = New-Object IO.MemoryStream foreach ($part in @((BE16 $cls), (BE16 $path), (BE16 $code), (BE32 $v1), (BE32 $v2), (BE32 $v3), (BE32 $v4), (BE32 0))) { $cmd.Write([byte[]]$part, 0, $part.Length) } $c = $cmd.ToArray() $payload = [byte[]]((BE16 1) + (BE16 ($c.Length + 2)) + $c) Send-Frame $conn.stream 0x2101 $payload $f = Recv-Frame $conn.stream if ($f.ftype -ne 0x2102 -or $f.data.Length -lt 18) { throw 'unexpected answer' } [byte[]]$d = $f.data $o = 4 # count(2) + length(2), then the answer body for ($i = 0; $i -lt 6; $i++) { if ($d[$o + $i] -ne $c[$i]) { throw 'answer does not match request' } } for ($i = 6; $i -lt 12; $i++) { if ($d[$o + $i] -ne 0) { return $null } } $dlen = RdU16 $d ($o + 12) if ($dlen -le 0) { return $null } $res = New-Object byte[] $dlen [Array]::Copy($d, $o + 14, $res, 0, $dlen) return , $res } function Focas-Open([string]$ip) { $client = New-Object Net.Sockets.TcpClient try { $ar = $client.BeginConnect($ip, $FocasPort, $null, $null) if (-not $ar.AsyncWaitHandle.WaitOne(1500)) { throw 'connect timeout' } $client.EndConnect($ar) $stream = $client.GetStream() $stream.ReadTimeout = 2500 $stream.WriteTimeout = 2500 Send-Frame $stream 0x0101 ([byte[]](0, 2)) $f = Recv-Frame $stream if ($f.ftype -ne 0x0102) { throw 'no FOCAS handshake' } $conn = @{ ip = $ip; client = $client; stream = $stream; comments = @{} } [byte[]]$s = Focas-Read $conn 0x18 if ($null -eq $s -or $s.Length -lt 18) { throw 'no system info' } $conn.type = RdText $s 4 2 $conn.kind = RdText $s 6 2 $conn.series = RdText $s 8 4 $conn.version = RdText $s 12 4 $conn.axes = RdText $s 16 2 # unique ID of the control: stays the same when its IP address changes $conn.id = '' [byte[]]$id = Focas-Read $conn 0xE8 if ($null -ne $id -and $id.Length -ge 16) { $conn.id = (($id[0..15] | ForEach-Object { $_.ToString('x2') }) -join '') } # channels (paths): a second or third channel answers the same system-info request $conn.paths = 1 foreach ($p in 2..4) { [byte[]]$more = Focas-Read $conn 0x18 0 0 0 $p if ($null -eq $more -or $more.Length -lt 18) { break } $conn.paths = $p } return $conn } catch { $client.Close() throw } } function Focas-Close($conn) { try { Send-Frame $conn.stream 0x0201 ([byte[]]@()); [void](Recv-Frame $conn.stream) } catch { } try { $conn.client.Close() } catch { } } function RdI16([byte[]]$b, [int]$o) { $v = RdU16 $b $o; if ($v -ge 0x8000) { $v -= 0x10000 }; return $v } # Meter record: i32 value, i16 base (10), i16 decimals. function Rd-Meter([byte[]]$b, [int]$o) { $v = RdI32 $b $o; $base = RdI16 $b ($o + 4); $dec = RdI16 $b ($o + 6) if ($base -gt 1 -and $dec -gt 0) { return [int][math]::Round($v / [math]::Pow($base, $dec)) } return $v } # What one channel does right now. Requests measured 26.10.2026 against the FANUC library on # 31i, 32i, 18i, 0i-F and Citizen/Tornos controls; values compared with the library on 7 machines. function Read-Live($conn, [int]$p) { $live = [ordered]@{ tool = $null; speed = $null; load = $null; axes = @(); feed = $null; line = $null; alarms = @() } [byte[]]$t = Focas-Read $conn 0x22 22 0 0 $p if ($null -ne $t -and $t.Length -ge 4) { $live.tool = RdI32 $t 0 } [byte[]]$sp = Focas-Read $conn 0x40 5 -1 0 $p if ($null -ne $sp -and $sp.Length -ge 8) { $live.speed = Rd-Meter $sp 0 } [byte[]]$sl = Focas-Read $conn 0x40 4 -1 0 $p if ($null -ne $sl -and $sl.Length -ge 8) { $live.load = Rd-Meter $sl 0 } # axis names do not change while the connection lives: ask once per channel if (-not $conn.ContainsKey('axisNames')) { $conn.axisNames = @{} } [byte[]]$names = $conn.axisNames[$p] if ($null -eq $names) { $names = Focas-Read $conn 0x89 0 0 0 $p; if ($null -ne $names) { $conn.axisNames[$p] = $names } } [byte[]]$loads = Focas-Read $conn 0x56 1 0 0 $p if ($null -ne $names -and $null -ne $loads) { $seen = @{} for ($i = 0; ($i * 4) -lt $names.Length -and (($i + 1) * 8) -le $loads.Length; $i++) { $n = ([char]$names[$i * 4]).ToString().Trim([char]0, ' ') if (-not $n -or $n -match '^\d$') { continue } # 18i lists unused axis slots as "7", "8" if ($seen.ContainsKey($n)) { $n = "$n$($i + 1)" } $seen[$n] = 1 $live.axes += [ordered]@{ name = $n; load = [math]::Abs([long](Rd-Meter $loads ($i * 8))) } } } # feed override: PMC G12, inverted (155 = 100 %); channel n at +1000*(n-1) $g = 1000 * ($p - 1) [byte[]]$g12 = Focas-Read $conn 0x8001 (12 + $g) (12 + $g) 0 1 0 2 if ($null -ne $g12 -and $g12.Length -ge 1 -and $g12[0] -ne 0) { $live.feed = 255 - $g12[0] } [byte[]]$ex = Focas-Read $conn 0x20 240 0 0 $p if ($null -ne $ex -and $ex.Length -gt 4) { $text = [Text.Encoding]::ASCII.GetString($ex, 4, $ex.Length - 4).Split([char]0)[0] -replace "`r", '' $first = $text.Split("`n")[0].Trim() if ($first) { $live.line = $first.Substring(0, [math]::Min(120, $first.Length)) } } [byte[]]$al = Focas-Read $conn 0x23 -1 10 2 $p 64 if ($null -ne $al) { for ($o = 0; ($o + 80) -le $al.Length -and $live.alarms.Count -lt 5; $o += 80) { $num = RdI32 $al $o; $len = [math]::Max(0, [math]::Min(64, (RdI32 $al ($o + 12)))) $msg = [Text.Encoding]::ASCII.GetString($al, $o + 16, $len).Trim() if ($num -gt 0 -or $msg) { $live.alarms += ("$num $msg").Trim() } } } return $live } $RunNames = @{ 0 = 'IDLE'; 1 = 'STOPPED'; 2 = 'HOLD'; 3 = 'RUNNING'; 4 = 'RUNNING' } $KindNames = @{ 'T' = 'Lathe'; 'TT' = 'Lathe, multi-channel'; 'M' = 'Mill'; 'MM' = 'Mill, multi-channel' } function Read-Snapshot($conn, [switch]$WithLive) { $m = [ordered]@{ ip = $conn.ip; online = $true; id = $conn.id; channels = @() model = ('FANUC ' + $conn.type + 'i'); kind = $KindNames[$conn.kind] software = ($conn.series + ' / ' + $conn.version); axes = $conn.axes state = $null; program = $null; comment = $null; counter = $null } foreach ($p in 1..$conn.paths) { $state = $null [byte[]]$st = Focas-Read $conn 0x19 0 0 0 $p if ($null -ne $st -and $st.Length -ge 14) { $state = $RunNames[(RdU16 $st 2)] if ($null -eq $state) { $state = 'IDLE' } if ($state -ne 'RUNNING') { if ((RdU16 $st 10) -ne 0) { $state = 'ALARM' } if ((RdU16 $st 8) -ne 0) { $state = 'EMERGENCY STOP' } } } [byte[]]$pn = Focas-Read $conn 0x1c 0 0 0 $p if ($null -eq $pn -or $pn.Length -lt 8) { continue } $main = RdI32 $pn 4 $key = "$p/$main" if (-not $conn.comments.ContainsKey($key)) { $conn.comments[$key] = '' [byte[]]$dir = Focas-Read $conn 0x06 $main 0x13 2 $p if ($null -ne $dir -and $dir.Length -ge 72 -and (RdI32 $dir 0) -eq $main) { $conn.comments[$key] = (RdText $dir 8 64).Trim('(', ')', '<', '>', ' ') } } $live = $null if ($WithLive) { # only the live poll; the network scan stays quick # a refused request is just a missing value; a broken connection marks the machine offline try { $live = Read-Live $conn $p } catch [System.IO.IOException] { throw } catch { if ($_.Exception.Message -match 'closed|unexpected|not a FOCAS|does not match') { throw } } } $m.channels += [ordered]@{ channel = $p; state = $state; program = ('O' + $main); comment = $conn.comments[$key]; live = $live } if ($p -eq 1) { $m.program = ('O' + $main); $m.comment = $conn.comments[$key] } } # the machine runs when at least one channel runs; otherwise the worst channel state is shown $states = @($m.channels | ForEach-Object { $_.state }) foreach ($s in @('RUNNING', 'EMERGENCY STOP', 'ALARM')) { if ($null -eq $m.state -and $states -contains $s) { $m.state = $s } } if ($null -eq $m.state -and $states.Count -gt 0) { $m.state = $states[0] } [byte[]]$mc = Focas-Read $conn 0x15 3901 3901 if ($null -ne $mc -and $mc.Length -ge 8 -and -not ($mc[6] -eq 0xFF -and $mc[7] -eq 0xFF) -and $mc[5] -gt 0) { $m.counter = [long][math]::Round((RdI32 $mc 0) / [math]::Pow($mc[5], $mc[7])) } return $m } function Get-LocalPrefixes { $found = @() foreach ($nic in [Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces()) { if ($nic.OperationalStatus -ne 'Up') { continue } foreach ($a in $nic.GetIPProperties().UnicastAddresses) { $ip = $a.Address.ToString() if ($ip -match '^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)\d') { $found += ($ip -replace '\.\d+$', '') } } } return @($found | Select-Object -Unique) } function Find-OpenPorts([string]$prefix) { $tries = @() foreach ($i in 1..254) { $c = New-Object Net.Sockets.TcpClient $ip = "$prefix.$i" $tries += @{ ip = $ip; client = $c; task = $c.ConnectAsync($ip, $FocasPort) } } Start-Sleep -Milliseconds 900 $open = @() foreach ($t in $tries) { if ($t.task.Status -eq 'RanToCompletion' -and $t.client.Connected) { $open += $t.ip } try { $t.client.Close() } catch { } } return @($open) } # FANUC network cards have addresses starting 00-E0-E4. After the scan the PC has seen every # live device of the subnet, so its own neighbour table tells which FANUC devices exist. function Get-FanucNeighbours { $ips = @() try { foreach ($line in (& arp -a 2>$null)) { if ($line -match '^\s*(\d+\.\d+\.\d+\.\d+)\s+00-e0-e4-') { $ips += $Matches[1] } } } catch { } return @($ips) } $Conns = @{} function Get-Machine([string]$ip, [switch]$WithLive) { try { if (-not $Conns.ContainsKey($ip)) { $Conns[$ip] = Focas-Open $ip } return Read-Snapshot $Conns[$ip] -WithLive:$WithLive } catch { if ($Conns.ContainsKey($ip)) { Focas-Close $Conns[$ip]; $Conns.Remove($ip) } return [ordered]@{ ip = $ip; online = $false; error = "$($_.Exception.Message)" } } } function Invoke-Scan { $sw = [Diagnostics.Stopwatch]::StartNew() $prefixes = Get-LocalPrefixes $machines = @() $silent = @() $answered = @() foreach ($p in $prefixes) { foreach ($ip in (Find-OpenPorts $p)) { $answered += $ip $m = Get-Machine $ip if ($m.online) { $machines += $m } else { $silent += $ip } } } $closed = @(Get-FanucNeighbours | Where-Object { $answered -notcontains $_ }) return @{ networks = @($prefixes | ForEach-Object { "$_.0/24" }); machines = @($machines); portOpenNoAnswer = @($silent); fanucClosed = $closed; ms = $sw.ElapsedMilliseconds } } $Page = @'
Finds FANUC controls on this network and shows them live. Read-only: nothing is written to a machine, nothing leaves this computer.