Last active
February 16, 2026 08:36
-
-
Save dutchLuck/cff79b7a93dc656263eb746a42102347 to your computer and use it in GitHub Desktop.
Python script to leverage ping and arp to discover IP to ID/name association via known MAC addresses on the local network when DHCP shifts IP around and local DNS is dodgy.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # | |
| # N E T W O R K _ S C A N . P Y | |
| # | |
| # network_scan.py last edited on Wed Feb 16 19:26:35 2026 as 0v8 | |
| # | |
| # Ping scan the local network for devices with known MAC addresses | |
| # and record their IPv4 addresses and ping status. The results are | |
| # displayed in two tables, one sorted by device name and the other | |
| # sorted by IP address. | |
| # | |
| # | |
| # This code was produced by AI from a prompt originating | |
| # in a desire to work around the local network suffering | |
| # from unreliable DNS and the DHCP occasionally shifting | |
| # the IPv4 addresses of devices on the local network. | |
| # | |
| # | |
| # Please DO NOT use this script on any network that | |
| # you do not own or do not have the permission of the | |
| # network CZAR to scan. It is only applicable to a | |
| # home network with just a few devices attached. If used | |
| # in a work environment it may trigger anti-virus security | |
| # monitoring systems etc. | |
| # | |
| # | |
| # Leverage the Operating System ping and arp/ip utility | |
| # programs to find the IP addresses of devices expected | |
| # to be on the local network. The known devices are read | |
| # from afile named "devices.txt" which matches identifiers | |
| # like names, although not necessarily DNS names, with | |
| # an associated MAC address. | |
| # An example of the file (IEEE MAC format) is; - | |
| # | |
| # # ComputerName MAC | |
| # Apple_1 26-6B-81-44-D6-75 | |
| # PC_1 01-06-AC-21-EC-85 | |
| # PC_2 51-33-E3-93-5F-3B | |
| # Gateway 1C-21-B4-25-07-2D | |
| # Broadcast_Address FF-FF-FF-FF-FF-FF | |
| # | |
| # | |
| # This code should work on an Apple mac, Linux, | |
| # and Microsoft Windows. | |
| # | |
| # It is known to run, with a 15 or 20 seconds delay | |
| # on macOS Tahoe 26.2 | |
| # (AI suggested the "arpscan" utility was a fast | |
| # alternative, if you are on linux.) | |
| # On macOS and Windows, native ping and arp utilities are used. | |
| # On Linux, native ping and ip utilities are used. | |
| # | |
| # There are no command line parameters to specify so run with; - | |
| # | |
| # python3 network_scan.py | |
| # | |
| # OR on macOS and Linux after using the command "chmod u+x network_scan.py" | |
| # | |
| # ./network_scan.py | |
| # | |
| # | |
| # For example, a hypothetical run may produce the | |
| # following output after a minute or two; - | |
| # | |
| # % ./network_scan.py | |
| # network_scan.py 0v7 interpreted by python version 3.14.0 | |
| # Name IP Address MAC Address Ping Result | |
| # -------------------------------------------------------------------- | |
| # Apple_1 192.168.1.100 26-6B-81-44-D6-75 Success | |
| # Gateway 192.168.1.1 1C-21-B4-25-07-2D Success | |
| # Multicast_Address 224.0.0.251 01-00-5E-00-00-FB unknown | |
| # PC_1 192.168.1.102 01-06-AC-21-EC-85 Failed | |
| # | |
| # IP Address Name MAC Address Ping Result | |
| # -------------------------------------------------------------------- | |
| # 192.168.1.1 Gateway 1C-21-B4-25-07-2D Success | |
| # 192.168.1.100 Apple_1 26-6B-81-44-D6-75 Success | |
| # 192.168.1.102 PC_1 01-06-AC-21-EC-85 Failed | |
| # 224.0.0.251 Multicast_Address 01-00-5E-00-00-FB unknown | |
| # % | |
| # | |
| # Note that a device with a "Failed" Ping Result may still be active, | |
| # but have a firewall that blocks ping access or it may be off/asleep, | |
| # but was active a short time ago and its entry has not yet aged out | |
| # of the macOS arp cache. | |
| # | |
| # | |
| # If your local network uses a different IP range then edit | |
| # the SUBNET, NETWORK_START and NETWORK_END IP addresses in the | |
| # "Constants" section below. | |
| # | |
| # | |
| # 0v8 Convert all MAC address use to IEEE format (E.g. 01-20-33-04-AA-FF). | |
| # 0v7 Added Windows support with proper ping and arp parsing. | |
| # 0v6 Switch from arp to ip neigh on linux. | |
| # 0v5 First cut at running on both macOS and linux. | |
| # 0v4 Fixed input parsing to restore name output. | |
| # 0v3 Changed input parsing to cope with IEEE MAC format. | |
| # 0v2 Changed to threaded implementation to speed up the response. | |
| # 0v1 Output in two tables 1 sorted by ID/Name and the other by IP.. | |
| # | |
| #------------------------------ | |
| # Start of python script | |
| #------------------------------ | |
| import platform # python_version() | |
| import subprocess | |
| import ipaddress | |
| import time | |
| import re # regular expressions | |
| from collections import defaultdict | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| #---------------------------- | |
| # Constants | |
| #---------------------------- | |
| VERSION_ID = "0v8" | |
| DEVICE_FILE = "devices.txt" | |
| SUBNET = "192.168.1" | |
| NETWORK_START = "192.168.1.1" # Start of DHCP allocation range | |
| NETWORK_END = "192.168.1.254" # End of IP scan range | |
| MAX_THREADS = 50 # sweet spot for most home networks | |
| #------------------------------ | |
| # Print version information | |
| #------------------------------ | |
| print(f"network_scan.py {VERSION_ID} interpreted by python version {platform.python_version()}") | |
| OS_TYPE = platform.system().lower() # 'darwin', 'linux', or 'windows' | |
| #----------------------- | |
| # OS dependent Constants | |
| #----------------------- | |
| WAIT_TIME = 2000 # Default (macOS) ping timeout in milliseconds | |
| INCOMPLETE_FLAG = "(incomplete)" | |
| PING_COUNT_FLAG = "-c" | |
| PING_TIMEOUT_FLAG = "-W" | |
| if OS_TYPE == "linux": | |
| WAIT_TIME = 2 # Linux ping uses seconds | |
| INCOMPLETE_FLAG = "<incomplete>" | |
| elif OS_TYPE == "windows": | |
| WAIT_TIME = 2000 # Windows ping uses milliseconds | |
| PING_COUNT_FLAG = "-n" | |
| PING_TIMEOUT_FLAG = "-w" | |
| #--------------------------- | |
| # Helper formatting function | |
| #--------------------------- | |
| def any_mac_address_to_IEEE_format(mac: str) -> str: | |
| """ | |
| Convert any H/W MAC Address format (E.G. 0:11:2:33:40:5a) | |
| to IEEE format (00-11-02-33-40-5A) with hyphens, leading zeros and uppercase | |
| """ | |
| parts = re.split(r"[:-]", mac) | |
| return "-".join(f"{int(part, 16):02X}" for part in parts) | |
| # -------------------------------- | |
| # Load known devices MAC addresses | |
| # -------------------------------- | |
| known_devices = {} | |
| with open(DEVICE_FILE, "r") as f: | |
| for line in f: | |
| if line.strip(): | |
| if not line or line.startswith("#"): #skip comments or blank lines | |
| continue | |
| name, mac = line.strip().split() # Expecting "Name MAC" format in devices.txt | |
| mac = any_mac_address_to_IEEE_format(mac) # Convert to IEEE format for consistency | |
| known_devices[mac] = name | |
| # ----------------------------- | |
| # Ping sweep | |
| # ----------------------------- | |
| def ping(ip): | |
| result = subprocess.run( | |
| ["ping", PING_COUNT_FLAG, "1", PING_TIMEOUT_FLAG, str(WAIT_TIME), str(ip)], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| return ip, (result.returncode == 0) | |
| #----------------------------------- | |
| # Generate IP List | |
| #---------------------------------- | |
| start_ip = int(ipaddress.IPv4Address(NETWORK_START)) | |
| end_ip = int(ipaddress.IPv4Address(NETWORK_END)) | |
| ip_list = [str(ipaddress.IPv4Address(ip)) for ip in range(start_ip, end_ip + 1)] | |
| ping_result_table = {} | |
| # ----------------------------- | |
| # Parallel ping sweep | |
| # ----------------------------- | |
| with ThreadPoolExecutor(max_workers=MAX_THREADS) as executor: | |
| futures = [executor.submit(ping, ip) for ip in ip_list] | |
| for future in as_completed(futures): | |
| result_ip, result = future.result() | |
| if result: | |
| ping_result_table[str(result_ip)] = "Success" | |
| else: | |
| ping_result_table[str(result_ip)] = "Failed" | |
| # Let ARP cache settle | |
| time.sleep(1) | |
| # ----------------------------------- | |
| # Read ARP table - platform dependent | |
| # | |
| # The arp utility on macOS and Windows, | |
| # and the ip neigh utility on linux | |
| # all show the ARP cache, but in | |
| # different formats. Some linux | |
| # distributions may not have the arp | |
| # utility by default, but all should | |
| # have ip neigh. | |
| # ----------------------------------- | |
| responsive_arp_ips = [] | |
| if OS_TYPE == "darwin": | |
| arp_output = subprocess.check_output(["arp", "-a"], text=True) # macOS arp -a format | |
| elif OS_TYPE == "linux": | |
| arp_output = subprocess.check_output(["ip", "neigh", "show"], text=True) # Linux ip neigh format | |
| elif OS_TYPE == "windows": | |
| arp_output = subprocess.check_output(["arp", "-a"], text=True) # Windows arp -a format | |
| arp_table = {} | |
| for line in arp_output.splitlines(): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| if OS_TYPE == "linux": # Linux ip neigh output format | |
| if not line.startswith(SUBNET + "."): | |
| continue | |
| parts = line.split() | |
| if len(parts) < 5: | |
| continue | |
| ip = str(ipaddress.IPv4Address(parts[0])) # Validate and standardize IP format | |
| arp_table[ip] = any_mac_address_to_IEEE_format(parts[4].lower()) | |
| if SUBNET in ip: | |
| responsive_arp_ips.append(str(ip)) | |
| elif OS_TYPE == "darwin": # macOS arp -a output format | |
| parts = line.split() | |
| if len(parts) >= 4 and parts[3] != INCOMPLETE_FLAG: | |
| ip = str(ipaddress.IPv4Address(parts[1].strip("()"))) # Validate and standardize IP format | |
| arp_table[ip] = any_mac_address_to_IEEE_format(parts[3].lower()) | |
| if SUBNET in ip: | |
| responsive_arp_ips.append(str(ip)) | |
| elif OS_TYPE == "windows": # Windows arp -a output format | |
| # Format: " 192.168.1.1 aa-bb-cc-dd-ee-ff dynamic" | |
| # Skip header lines and empty lines | |
| if "Interface" in line or "Address" in line or "---" in line or not line: # Skip non-data lines | |
| continue | |
| parts = line.split() | |
| if len(parts) >= 2: | |
| try: | |
| # Validate IP address format and standardize it | |
| ip = str(ipaddress.IPv4Address(parts[0])) # Validate and standardize IP format | |
| # Get MAC address (second column) | |
| mac = parts[1].lower() if len(parts) > 1 else None | |
| # Validate MAC format (should have hyphens or colons) | |
| if mac and ("-" in mac or ":" in mac): | |
| arp_table[ip] = any_mac_address_to_IEEE_format(mac) | |
| if SUBNET in ip: | |
| responsive_arp_ips.append(str(ip)) | |
| except (ValueError, ipaddress.AddressValueError): | |
| # Skip invalid IP addresses | |
| continue | |
| # ----------------------------- | |
| # Build results | |
| # ----------------------------- | |
| results = [] | |
| known_name_max = 0 | |
| known_ip_max = 0 | |
| known_mac_max = 0 | |
| for ip in responsive_arp_ips: | |
| mac = arp_table.get(ip, "unknown") | |
| name = known_devices.get(mac, "Unknown Device") | |
| ping_success = ping_result_table.get(ip, "unknown") | |
| results.append((name, ip, mac, ping_success)) | |
| if len(name) > known_name_max: | |
| known_name_max = len(name) | |
| if len(ip) > known_ip_max: | |
| known_ip_max = len(ip) | |
| if len(mac) > known_mac_max: | |
| known_mac_max = len(mac) | |
| name_width = known_name_max + 2 # set up output width | |
| ip_width = known_ip_max + 2 # set up output width | |
| mac_width = known_mac_max + 2 # set up output width | |
| # ------------------------------------- | |
| # Output table in Name, IP, Mac columns | |
| # ------------------------------------- | |
| # Sort by name | |
| results.sort(key=lambda x: x[0].lower()) | |
| print(f"{'Name':{name_width}} {'IP Address':{ip_width}} {'MAC Address':{mac_width}} {'Ping Result'}") | |
| print("-" * (14 + name_width + ip_width + mac_width)) | |
| for name, ip, mac, ping_success in results: | |
| print(f"{name:{name_width}} {ip:{ip_width}} {mac:{mac_width}} {ping_success}") | |
| # ------------------------------------- | |
| # Output table in IP, Name, Mac columns | |
| # ------------------------------------- | |
| # Sort by IP | |
| results.sort(key=lambda x: x[1].lower()) | |
| print(f"\n{'IP Address':{ip_width}} {'Name':{name_width}} {'MAC Address':{mac_width}} {'Ping Result'}") | |
| print("-" * (14 + name_width + ip_width + mac_width)) | |
| for name, ip, mac, ping_success in results: | |
| print(f"{ip:{ip_width}} {name:{name_width}} {mac:{mac_width}} {ping_success}") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment