1+ #!/usr/bin/env python3
2+ """
3+ Multi-method WiFi device scanner.
4+ Combines ARP, ping sweep, and nmap for best coverage.
5+ """
6+
7+ import socket
8+ import subprocess
9+ import ipaddress
10+ import requests
11+ import sys
12+ import os
13+ from concurrent .futures import ThreadPoolExecutor , as_completed
14+
15+ # Try importing scapy
16+ try :
17+ from scapy .all import ARP , Ether , srp , conf
18+ SCAPY_AVAILABLE = True
19+ except ImportError :
20+ SCAPY_AVAILABLE = False
21+
22+ def get_local_ip_and_subnet ():
23+ s = socket .socket (socket .AF_INET , socket .SOCK_DGRAM )
24+ s .connect (("8.8.8.8" , 80 ))
25+ ip = s .getsockname ()[0 ]
26+ s .close ()
27+ subnet = ip .rsplit ('.' , 1 )[0 ] + ".0/24"
28+ return ip , subnet
29+
30+ def get_vendor (mac ):
31+ try :
32+ url = f"https://api.macvendors.com/{ mac } "
33+ r = requests .get (url , timeout = 3 )
34+ if r .status_code == 200 :
35+ return r .text .strip ()
36+ except :
37+ pass
38+ return "Unknown"
39+
40+ def get_hostname (ip ):
41+ try :
42+ return socket .gethostbyaddr (ip )[0 ]
43+ except :
44+ return ""
45+
46+ def ping_host (ip ):
47+ """Ping a single IP. Returns IP if alive, else None."""
48+ result = subprocess .run (
49+ ["ping" , "-c" , "1" , "-W" , "1" , str (ip )],
50+ stdout = subprocess .DEVNULL ,
51+ stderr = subprocess .DEVNULL
52+ )
53+ return str (ip ) if result .returncode == 0 else None
54+
55+ def ping_sweep (subnet ):
56+ """Ping every host in the subnet in parallel."""
57+ print (" [1/3] Running ping sweep (wakes up sleeping devices)..." )
58+ network = ipaddress .ip_network (subnet , strict = False )
59+ alive = set ()
60+ hosts = list (network .hosts ())
61+ with ThreadPoolExecutor (max_workers = 100 ) as executor :
62+ futures = {executor .submit (ping_host , ip ): ip for ip in hosts }
63+ for future in as_completed (futures ):
64+ result = future .result ()
65+ if result :
66+ alive .add (result )
67+ print (f" → Found { len (alive )} host(s) responding to ping" )
68+ return alive
69+
70+ def arp_scan (subnet ):
71+ """ARP scan using scapy."""
72+ print (" [2/3] Running ARP scan..." )
73+ if not SCAPY_AVAILABLE :
74+ print (" → Scapy not available, skipping" )
75+ return {}
76+
77+ conf .verb = 0
78+ packet = Ether (dst = "ff:ff:ff:ff:ff:ff" ) / ARP (pdst = subnet )
79+ answered , _ = srp (packet , timeout = 3 , verbose = False , retry = 2 )
80+
81+ results = {}
82+ for sent , received in answered :
83+ results [received .psrc ] = received .hwsrc
84+ print (f" → Found { len (results )} host(s) via ARP" )
85+ return results
86+
87+ def read_arp_table ():
88+ """Read the kernel ARP cache using 'ip neigh' (modern Linux)."""
89+ print (" [3/3] Reading ARP cache..." )
90+ mac_map = {}
91+ try :
92+ output = subprocess .check_output (["ip" , "neigh" ], text = True )
93+ for line in output .splitlines ():
94+ parts = line .split ()
95+ # Format: <IP> dev <iface> lladdr <MAC> <STATE>
96+ if "lladdr" in parts :
97+ ip = parts [0 ]
98+ mac = parts [parts .index ("lladdr" ) + 1 ]
99+ if "FAILED" not in line and "INCOMPLETE" not in line :
100+ mac_map [ip ] = mac
101+ except Exception as e :
102+ print (f" → Could not read ARP table: { e } " )
103+ print (f" → { len (mac_map )} entr(ies) in ARP cache" )
104+ return mac_map
105+
106+ def is_randomized_mac (mac ):
107+ """
108+ Phones randomize their MAC. The 2nd-least-significant bit of the first octet
109+ being set (locally administered) is the giveaway.
110+ e.g. 22:... 12:... A2:... — first octet is even but bit 1 is set.
111+ """
112+ try :
113+ first_octet = int (mac .split (":" )[0 ], 16 )
114+ return bool (first_octet & 0x02 ) # locally administered bit
115+ except :
116+ return False
117+
118+ def guess_device_type (mac , hostname , vendor ):
119+ """Make a best-guess at device type from available clues."""
120+ if is_randomized_mac (mac ):
121+ return "📱 Phone/Tablet (randomized MAC — privacy mode)"
122+ h = (hostname + vendor ).lower ()
123+ if any (x in h for x in ["iphone" , "apple" , "ipad" ]):
124+ return "🍎 Apple device"
125+ if any (x in h for x in ["android" , "samsung" , "xiaomi" , "huawei" , "oppo" , "oneplus" ]):
126+ return "📱 Android device"
127+ if any (x in h for x in ["router" , "gateway" , "dlink" , "tp-link" , "tplink" , "asus" , "netgear" , "huawei" , "zte" , "technicolor" ]):
128+ return "📡 Router/Gateway"
129+ if any (x in h for x in ["windows" , "desktop" , "laptop" , "intel" , "realtek" ]):
130+ return "💻 PC/Laptop"
131+ if any (x in h for x in ["ubuntu" , "linux" , "debian" , "arch" , "raspi" , "raspberry" ]):
132+ return "🐧 Linux device"
133+ if any (x in h for x in ["smart" , "tv" , "cast" , "roku" , "fire" , "echo" , "alexa" , "nest" , "ring" ]):
134+ return "📺 Smart TV/IoT"
135+ if vendor and vendor != "Unknown" :
136+ return f"🔌 { vendor } "
137+ return "❓ Unknown"
138+
139+ def format_mac (mac ):
140+ """Normalise to upper-case colon-separated."""
141+ return mac .replace ("-" , ":" ).upper ()
142+
143+ def scan (subnet ):
144+ print (f"\n Scanning { subnet } (this may take ~15 seconds)\n " )
145+
146+ # Step 1: ping sweep — forces devices to appear in ARP table
147+ ping_sweep (subnet )
148+
149+ # Step 2: ARP scan via scapy
150+ arp_direct = arp_scan (subnet )
151+
152+ # Step 3: Read kernel ARP cache (populated by ping sweep)
153+ arp_cache = read_arp_table ()
154+
155+ # Merge all sources
156+ all_ips = set (arp_direct .keys ()) | set (arp_cache .keys ())
157+
158+ devices = []
159+ for ip in sorted (all_ips , key = lambda x : list (map (int , x .split ('.' )))):
160+ mac = arp_direct .get (ip ) or arp_cache .get (ip ) or "??:??:??:??:??:??"
161+ mac = format_mac (mac )
162+ devices .append ({"ip" : ip , "mac" : mac })
163+
164+ return devices
165+
166+ def enrich (devices , my_ip ):
167+ print (f"\n Enriching { len (devices )} device(s) (hostname + vendor)...\n " )
168+ enriched = []
169+ for d in devices :
170+ hostname = get_hostname (d ["ip" ])
171+ vendor = get_vendor (d ["mac" ]) if not is_randomized_mac (d ["mac" ]) else "N/A (randomized)"
172+ dtype = guess_device_type (d ["mac" ], hostname , vendor )
173+ label = " ← THIS MACHINE" if d ["ip" ] == my_ip else ""
174+ enriched .append ({** d , "hostname" : hostname , "vendor" : vendor , "type" : dtype , "label" : label })
175+ return enriched
176+
177+ def print_table (devices ):
178+ print ("\n " + "═" * 90 )
179+ print (f" { 'IP' :<16} { 'MAC' :<20} { 'HOSTNAME' :<22} DEVICE TYPE" )
180+ print ("═" * 90 )
181+ for d in devices :
182+ hostname = (d ["hostname" ] or "-" )[:21 ]
183+ print (f" { d ['ip' ]:<16} { d ['mac' ]:<20} { hostname :<22} { d ['type' ]} { d ['label' ]} " )
184+ print ("═" * 90 )
185+ print (f" Total: { len (devices )} device(s) found" )
186+ rand = sum (1 for d in devices if is_randomized_mac (d ["mac" ]))
187+ if rand :
188+ print (f" ℹ { rand } device(s) use randomized MACs (modern phones/tablets) — vendor lookup not possible" )
189+ print ()
190+
191+ if __name__ == "__main__" :
192+ if os .geteuid () != 0 :
193+ print ("⚠ Run with sudo for best results: sudo -E ./venv/bin/python scan_wifi.py" )
194+ sys .exit (1 )
195+
196+ my_ip , subnet = get_local_ip_and_subnet ()
197+ devices = scan (subnet )
198+
199+ if not devices :
200+ print ("\n No devices found. Try running with sudo." )
201+ sys .exit (0 )
202+
203+ enriched = enrich (devices , my_ip )
204+ print_table (enriched )
0 commit comments