Build a LAN Snapshot Logger
Build a Python tool that scans a local network and tracks ARP snapshots.
Introduction
30 Second Summary
The devices around you quietly connect or disappear throughout the day. A list from this morning cannot tell you what changed by tonight.
In this project, you will build a terminal-based LAN Snapshot Logger with Python, Scapy, plus SQLite. Each run turns ARP responses from your authorized local IPv4 network into a saved snapshot plus a report of changes since the previous scan.
What You'll Build
Your terminal shows the current device table followed by a history report that highlights arrivals, disappearances, unchanged devices, plus changed IP addresses.
By the end of this project, you'll have:
- A live device table showing every responding device through its IP address plus its MAC address.
- Reliable input guardrails that reject malformed IPv4 networks or scans above 256 addresses with clear error messages.
- A durable snapshot history that survives after the program exits. Later scans produce a change report covering new, missing, unchanged, plus re-addressed devices.
- Secret Mission: Export the newest saved snapshot as a spreadsheet-friendly CSV file.
Are there any prerequisites?
To use PyCharm, your Mac needs macOS 15 or 26, 8 GB of total RAM, 10 GB of disk space, plus administrator access. You'll also need GitHub Student Developer Pack eligibility plus access to an IPv4 network you own or have explicit permission to scan.
Before We Start
This first checkpoint defines the safe boundary for your project before any packets leave your Mac. Your read-only ARP inventory stays on an authorized IPv4 network that you own or have explicit permission to scan.
Set Up Your Python Networking Workspace
Your authorized ARP scanner needs a compatible Python runtime before it can send packets. Your Mac does not have that workspace yet.
This step checks that your Mac can run PyCharm. You will install the required tools through JetBrains Toolbox App. You will finish with an isolated virtual environment containing Scapy.
In this step, get ready to:
- Prepare a compatible Python 3.14.7 runtime.
- Activate your JetBrains student license.
- Build an isolated PyCharm workspace with Scapy 2.7.0.
Check your Mac and install Python
PyCharm needs a supported macOS release plus enough memory and storage. Checking these requirements first prevents an installation that your Mac cannot run reliably.
- Click the Apple menu in the upper-left corner of your screen.
- Select System Settings.
- Select General in the sidebar.
- Select About.
- Confirm the macOS value is not below version 15.
- Confirm the Memory value is at least 8 GB.
- Click Storage Settings.
- Confirm your Mac has at least 10 GB of available storage.
Pause at this checkpoint
The next installation requests administrator approval. Pausing here protects you from getting stuck halfway through the setup.
- Stop here if your macOS version is below 15.
- Stop here if you cannot approve administrator prompts on this Mac.
- Continue after your Mac meets the requirements.
Python may already be present from another project. A version check separates the ready state from the two installation states.
- Press Cmd+Space to open Spotlight.
- Type Terminal.
- Press Return to open Terminal.
- Check for the required Python release by running this command:
python3.14 -VV
What does this command check?
The -VV option prints the Python version plus additional build information. This confirms whether the python3.14 executable is available.
✔️ I see Python 3.14.7
Your Mac already has the required Python release. Keep Terminal open for the certificate step below.
ⓧ I see an older Python 3.14 version
Installing Python changes shared software on your Mac. macOS asks for an administrator password before it makes that change.
- Open the official Python 3.14.7 download page in your browser.
- Select the macOS installer.
- Open the downloaded .pkg file.
- Follow the macOS Installer prompts.
- Approve the administrator prompt with your Mac credentials.
- Complete the installation of Python 3.14.7.
ⓧ Command not found
Your Mac needs the official Python installer. The installer requests administrator approval before copying Python onto your Mac.
- Open the official Python 3.14.7 download page in your browser.
- Select the macOS installer.
- Open the downloaded .pkg file.
- Follow the macOS Installer prompts.
- Approve the administrator prompt with your Mac credentials.
- Complete the installation of Python 3.14.7.
The python.org installer includes a certificate setup command. Completing it allows Python programs to verify secure internet connections when packages are downloaded.
- Click Finder in the Dock.
- Select Applications in the Finder sidebar.
- Open the Python 3.14 folder.
- Double-click Install Certificates.command.
- Wait for its Terminal window to finish without reporting a certificate installation failure.
- Return to the original Terminal window.
- Verify the installed Python build by running this command:
python3.14 -VV
What should this confirm?
The first part of the output identifies Python 3.14.7. The remaining text identifies the build used by this interpreter.
You should now see a Python 3.14.7 build line. Your Mac has the runtime that creates the project environment later in this step.
Still seeing the wrong Python version?
- Close Terminal after the installer finishes.
- Open a fresh Terminal window through Spotlight.
- Run the version check again.
If the required executable is still unavailable, help me troubleshoot my Python installation.
Activate your student license and install PyCharm
Your GitHub Student Developer Pack can authorize a renewable JetBrains educational license. Signing in to Toolbox with the same JetBrains Account allows it to activate that license for PyCharm automatically.
- Open the GitHub Student Developer Pack page in your browser.
- Select the JetBrains offer.
- Choose Authorize with GitHub on the JetBrains application form.
- Sign in to GitHub if prompted.
- Approve the JetBrains authorization request.
- Complete the final details requested by JetBrains.
- Confirm the license request through the message from JetBrains.
- Activate the educational license through the follow-up message from JetBrains.
- Sign in to the resulting JetBrains Account.
- Confirm that the account shows an active educational license.
How long does the student license last?
The JetBrains student license remains free while you stay eligible. You must renew the subscription annually.
Toolbox App 3.8.1 manages the PyCharm installation. Choose the tab that matches the Toolbox state on your Mac.
✔️ I have Toolbox App 3.8.1
Your Toolbox App is ready. Continue with the shared sign-in instructions below.
ⓧ I have an older version
- Open the official JetBrains Toolbox App download page.
- Download the macOS .dmg for Toolbox App 3.8.1.
- Choose the disk image that matches the chip shown in your Mac's About settings.
- Open the downloaded disk image.
- Drag the JetBrains Toolbox app into the Applications folder.
ⓧ Toolbox App is not installed
- Open the official JetBrains Toolbox App download page.
- Download the macOS .dmg for Toolbox App 3.8.1.
- Choose the disk image that matches the chip shown in your Mac's About settings.
- Open the downloaded disk image.
- Drag the JetBrains Toolbox app into the Applications folder.
The PyCharm download can take several minutes on a slower connection. Toolbox shows its progress throughout the installation.
- Press Cmd+Space to open Spotlight.
- Type JetBrains Toolbox.
- Press Return to open the app.
- Sign in with the JetBrains Account that holds your educational license.
- Select PyCharm in Toolbox.
- Click Install for the current stable PyCharm release.
- Wait for Toolbox to show that the installation is complete.
- Run PyCharm from Toolbox.
PyCharm may offer to import settings on its first launch. Skip the import when you have no earlier JetBrains configuration to reuse.
- Continue to the PyCharm welcome screen.
PyCharm is now installed under your activated JetBrains account. The next task gives it a dedicated folder and interpreter.
Toolbox or PyCharm not opening?
- Confirm that you downloaded the disk image matching your Mac's chip.
- Confirm that JetBrains Toolbox appears in the Applications folder.
- Confirm that Toolbox shows your JetBrains Account as signed in.
For environment-specific help, help me troubleshoot JetBrains Toolbox or PyCharm on my Mac.
Create the workspace and verify Scapy
A dedicated project folder keeps the scanner separate from other work. Its .venv directory gives PyCharm a project-specific interpreter.
- Click Finder in the Dock.
- Select Desktop in the Finder sidebar.
- Click File in the menu bar.
- Select New Folder.
- Enter lan-snapshot-logger as the folder name.
- Press Return to create the folder.
- Switch back to PyCharm.
- Click Open on the welcome screen.
PyCharm may ask whether you trust the folder. You created this empty folder yourself, so it is safe to approve the prompt.
- Select the lan-snapshot-logger folder on your Desktop.
- Click Open.
- Approve the trust prompt if PyCharm displays one.
- Select Terminal in PyCharm's bottom tool window bar.
- Create the isolated environment by running these commands:
python3.14 -m venv .venv
source .venv/bin/activate
What do these commands do?
- The first command creates an isolated Python environment in .venv.
- The second command activates that environment in the current terminal session.
- Packages installed through the activated interpreter stay inside this project.
Your terminal prompt should now include (.venv). That prompt confirms the isolated environment is active.
Virtual environment not activating?
- Confirm that PyCharm opened the lan-snapshot-logger folder.
- Confirm that the Project panel contains a .venv directory.
- Run the activation command from the integrated terminal inside that folder.
If the prompt still does not change, help me activate my PyCharm virtual environment.
Scapy provides the Ethernet and ARP packet types used by the scanner. Pinning the package to 2.7.0 keeps the project environment reproducible.
- Install the pinned Scapy package into the active environment by running:
python -m pip install 'scapy==2.7.0'
What does this command do?
The activated python executable runs pip inside .venv. The exact-version requirement installs Scapy 2.7.0.
The installation output should finish without a package installation failure. Your .venv now contains Scapy.
Scapy installation failing?
- Confirm that the terminal prompt includes (.venv).
- Confirm that the terminal has an active internet connection.
- Run the installation command again after the connection is restored.
For help with a persistent package failure, help me troubleshoot the Scapy installation.
PyCharm uses the selected project interpreter for code analysis and execution. Pointing it at the existing .venv/bin/python keeps the IDE aligned with the terminal.
- Click the Python Interpreter selector in PyCharm's status bar.
- Choose Add New Interpreter.
- Choose Add Local Interpreter.
- Select the existing interpreter at .venv/bin/python.
- Confirm the interpreter selection.
The status bar should identify the interpreter from lan-snapshot-logger/.venv/bin/python. PyCharm now uses the same environment that contains Scapy.
Before you run the final checks, what two successful results do you expect from the selected interpreter?
- Verify the Python build and Scapy imports by running:
python -VV
python -c "from scapy.layers.l2 import ARP, Ether; from scapy.sendrecv import srp; print('Scapy imports work')"
What do these checks prove?
- The first command asks the active project interpreter to print its version plus build details.
- The second command imports ARP plus Ether from Scapy's layer-two module.
- The same command imports srp for layer-two packet requests and responses.
You should see a Python 3.14.7 build line followed by Scapy imports work. That completes the workspace without creating a source file or sending a network packet.
That is the setup finished. Your isolated interpreter can now load every Scapy component required for the authorized ARP scanner.
Import check not passing?
- Confirm that PyCharm shows lan-snapshot-logger/.venv/bin/python as the selected interpreter.
- Confirm that the integrated terminal prompt includes (.venv).
- Reinstall Scapy inside the activated environment if the import mentions a missing package.
If the import still fails, help me debug the PyCharm interpreter and Scapy import.
Your Python networking workspace is ready. Next, you will turn those verified imports into a live ARP discovery program for your authorized network.
Discover Devices with ARP
Your Python workspace in PyCharm can now import Scapy. The next job is to turn that setup into a live view of your authorized network.
An ARP scan asks devices on your local IPv4 link to identify themselves. This step also tests whether the result remains available after the process exits.
In this step, get ready to:
- Record the IPv4 address and subnet mask for your authorized network.
- Build a scanner that sends ARP requests and prints responding devices.
- Run two scans to test what remains after each process exits.
Record your authorized network details
Your IPv4 address identifies your Mac on the current network. The subnet mask tells the scanner which addresses belong to the same local link.
- Select the Apple menu.
- Click System Settings.
- Select Network in the sidebar.
- Select the active network service your Mac currently uses.
- Click Details.
- Select TCP/IP.
The TCP/IP panel contains the two values your scanner needs. Keep both settings unchanged while you record them.
- Record the displayed IPv4 address here: your IPv4 address.
- Record the displayed subnet mask here: your subnet mask.
- Return to PyCharm after recording both values.
Keep the scan authorized
Use the scanner only on a network you own or have explicit permission to assess. The program limits each learning scan to 256 addresses.
ARP discovery covers the current local link. Devices on separate guest networks or virtual LANs remain outside this scan.
Build the ARP scanner
An Ethernet broadcast delivers the ARP question across the local link. Each response supplies an IP address plus the responding device's MAC address.
- Switch back to the Project tool window in PyCharm.
- Right-click the lan-snapshot-logger folder.
- Select New.
- Select Python File.
- Enter lan_snapshot in the file name field.
- Press Enter.
You should see lan_snapshot.py in the project tree. PyCharm opens the empty file in the editor.
- Add the scanner foundation to lan_snapshot.py by pasting this code:
from dataclasses import dataclass
from ipaddress import IPv4Address, IPv4Network
from scapy.layers.l2 import ARP, Ether
from scapy.sendrecv import srp
BROADCAST_MAC = "ff:ff:ff:ff:ff:ff"
MAX_ADDRESSES = 256
SCAN_TIMEOUT_SECONDS = 2
@dataclass(frozen=True)
class Device:
ip_address: str
mac_address: str
What does this foundation do?
- The imports provide network parsing plus packet construction.
- The constants hold the broadcast destination plus the scan limits.
- The frozen data class gives each response a structured IP address and MAC address.
- Freezing Device objects protects each observation from accidental changes.
- Add the network input loop below the Device class by pasting this code:
def prompt_for_network() -> IPv4Network:
while True:
raw_value = input(
"Enter IPv4 address/subnet mask "
"(example: 192.168.1.23/255.255.255.0): "
).strip()
try:
network = IPv4Network(raw_value, strict=False)
except ValueError as error:
print(f"Invalid IPv4 network: {error}")
continue
if network.num_addresses > MAX_ADDRESSES:
print(
f"This learning scanner allows at most {MAX_ADDRESSES} "
"addresses per run. Enter an authorized /24 or smaller network."
)
continue
return network
How does the input loop protect the scan?
- The loop keeps asking until it receives a usable IPv4 address and subnet mask.
- The strict=False setting normalizes the Mac's host address into its network address.
- The ValueError handler turns malformed input into a readable message.
- The address count check enforces the project's 256 address safety limit.
- Save lan_snapshot.py.
- Inspect the code through prompt_for_network() for red syntax error markers.
PyCharm should show the imports and input function without red syntax error markers.
Seeing an error in the input code?
- Check that IPv4Address and IPv4Network use the same capitalization as the imports.
- Check that the closing parentheses align with the matching function calls.
Ask for help with the exact editor warning: Help me fix the syntax error in my prompt_for_network function.
- Add the discovery functions below prompt_for_network() by pasting this code:
def device_sort_key(device: Device) -> IPv4Address:
return IPv4Address(device.ip_address)
def scan_network(network: IPv4Network) -> list[Device]:
request = Ether(dst=BROADCAST_MAC) / ARP(pdst=str(network))
answered, _ = srp(
request,
timeout=SCAN_TIMEOUT_SECONDS,
verbose=False,
)
devices_by_mac: dict[str, Device] = {}
for _, reply in answered:
arp_reply = reply[ARP]
device = Device(
ip_address=str(arp_reply.psrc),
mac_address=str(arp_reply.hwsrc).lower(),
)
devices_by_mac[device.mac_address] = device
return sorted(devices_by_mac.values(), key=device_sort_key)
How does discovery work?
- The request combines an Ethernet broadcast with an ARP question for the normalized network.
- The srp() call sends the layer 2 request for two seconds.
- A dictionary keyed by MAC address keeps one Device object per responder.
- The return statement sorts the final devices by their numeric IPv4 addresses.
- Add the terminal table function below scan_network() by pasting this code:
def print_devices(title: str, devices: list[Device]) -> None:
print(f"\n{title}")
if not devices:
print("No devices answered this ARP scan.")
return
print(f"{'IP address':<16} MAC address")
print(f"{'-' * 15:<16} {'-' * 17}")
for device in devices:
print(f"{device.ip_address:<16} {device.mac_address}")
print(f"Responding devices: {len(devices)}")
How does the table handle results?
- The empty-result branch gives you a clear outcome when no device answers.
- The format widths align each responding IP address with its MAC address.
- The final line counts the devices that answered during this scan.
- Save lan_snapshot.py.
- Inspect scan_network() and print_devices() for red syntax error markers.
PyCharm should recognize each imported Scapy name. It should also recognize every Device reference.
Seeing unresolved names?
- Confirm that the selected interpreter still ends with .venv/bin/python.
- Confirm that ARP, Ether, and srp match the imports at the top of the file.
- Confirm that devices_by_mac uses the same spelling everywhere.
Use this prompt if the warning remains: Help me diagnose unresolved names in my Scapy ARP scanner.
- Finish lan_snapshot.py by adding this code below print_devices():
def main() -> None:
network = prompt_for_network()
print(f"Scanning {network} with ARP...")
try:
devices = scan_network(network)
except OSError as error:
print(f"Scan failed: {error}")
print("Run this script with administrator privileges on macOS.")
return
print_devices("Current LAN snapshot", devices)
if __name__ == "__main__":
main()
How does the program run?
- The main() function collects the network before starting discovery.
- The try block catches packet permission failures as readable output.
- The final condition calls main() when you run lan_snapshot.py as a script.
- Save lan_snapshot.py.
✔️ Awesome, I've got everything!
Your scanner code is complete. Keep lan_snapshot.py saved before you continue.
ⓧ I'd like to double check the full code
from dataclasses import dataclass
from ipaddress import IPv4Address, IPv4Network
from scapy.layers.l2 import ARP, Ether
from scapy.sendrecv import srp
BROADCAST_MAC = "ff:ff:ff:ff:ff:ff"
MAX_ADDRESSES = 256
SCAN_TIMEOUT_SECONDS = 2
@dataclass(frozen=True)
class Device:
ip_address: str
mac_address: str
def prompt_for_network() -> IPv4Network:
while True:
raw_value = input(
"Enter IPv4 address/subnet mask "
"(example: 192.168.1.23/255.255.255.0): "
).strip()
try:
network = IPv4Network(raw_value, strict=False)
except ValueError as error:
print(f"Invalid IPv4 network: {error}")
continue
if network.num_addresses > MAX_ADDRESSES:
print(
f"This learning scanner allows at most {MAX_ADDRESSES} "
"addresses per run. Enter an authorized /24 or smaller network."
)
continue
return network
def device_sort_key(device: Device) -> IPv4Address:
return IPv4Address(device.ip_address)
def scan_network(network: IPv4Network) -> list[Device]:
request = Ether(dst=BROADCAST_MAC) / ARP(pdst=str(network))
answered, _ = srp(
request,
timeout=SCAN_TIMEOUT_SECONDS,
verbose=False,
)
devices_by_mac: dict[str, Device] = {}
for _, reply in answered:
arp_reply = reply[ARP]
device = Device(
ip_address=str(arp_reply.psrc),
mac_address=str(arp_reply.hwsrc).lower(),
)
devices_by_mac[device.mac_address] = device
return sorted(devices_by_mac.values(), key=device_sort_key)
def print_devices(title: str, devices: list[Device]) -> None:
print(f"\n{title}")
if not devices:
print("No devices answered this ARP scan.")
return
print(f"{'IP address':<16} MAC address")
print(f"{'-' * 15:<16} {'-' * 17}")
for device in devices:
print(f"{device.ip_address:<16} {device.mac_address}")
print(f"Responding devices: {len(devices)}")
def main() -> None:
network = prompt_for_network()
print(f"Scanning {network} with ARP...")
try:
devices = scan_network(network)
except OSError as error:
print(f"Scan failed: {error}")
print("Run this script with administrator privileges on macOS.")
return
print_devices("Current LAN snapshot", devices)
if __name__ == "__main__":
main()
Run two live scans
Scapy needs administrator privileges to send packets on macOS. This command scopes that approval to the Python interpreter inside your project.
Before you run it, how many devices do you expect to answer during the two-second scan?
- Return to PyCharm's integrated terminal.
- Start the authorized scan by running this command:
sudo .venv/bin/python lan_snapshot.py
- Approve the administrator request from macOS.
- Enter your IPv4 address followed by / followed by your subnet mask.
Your first live snapshot is working. You'll see Current LAN snapshot followed by a table of zero or more responding IP and MAC address pairs.
Scan not completing?
- Confirm that you approved the administrator request before the packet scan began.
- Re-enter the recorded IPv4 address and subnet mask if the input is rejected.
- Keep the network within the scanner's 256 address limit.
- Treat zero responses as a valid result when devices are sleeping or isolated from the local link.
Get help without sharing private network values: Help me troubleshoot my authorized ARP scan.
Before you run it again, do you think this new process can explain what changed since the first scan?
- Run the scanner a second time with this command:
sudo .venv/bin/python lan_snapshot.py
- Enter your IPv4 address followed by / followed by your subnet mask.
The second process prints only its current snapshot. It has no saved row or comparison from the first run.
You can now discover responding devices on your authorized local network. Next, you'll give each snapshot a durable identity and timestamp with SQLite.
Give the Scanner a Memory
Your Scapy scanner already reveals the devices that answer each authorized scan. The list disappears when the process exits.
A SQLite database gives each scan a durable identity. A single transaction keeps its network plus device observations together.
In this step, get ready to:
- Define a database schema for scans plus device observations.
- Save each scan with a UTC timestamp using parameterized queries.
- Query the database after the scanner exits to prove the history persists.
Define the database schema
The scans table gives every run an ID plus timestamp. The devices table links each observed IP and MAC address to that ID.
- Return to lan_snapshot.py in PyCharm.
- Select everything from the first import through the SCAN_TIMEOUT_SECONDS = 2 line.
- Replace the selected section with this code:
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from ipaddress import IPv4Address, IPv4Network
from scapy.layers.l2 import ARP, Ether
from scapy.sendrecv import srp
DB_PATH = "lan_snapshots.db"
BROADCAST_MAC = "ff:ff:ff:ff:ff:ff"
MAX_ADDRESSES = 256
SCAN_TIMEOUT_SECONDS = 2
What does this code add?
- The sqlite3 module connects your Python code to a local database file.
- The datetime plus timezone imports create timezone-aware UTC timestamps.
- DB_PATH keeps the database filename in one reusable constant.
- Save lan_snapshot.py.
- Confirm the editor shows no unresolved references in the new import section.
Seeing an unresolved import?
Check that sqlite3 appears before the data class import. Confirm that datetime plus timezone use the same import line shown above.
Help me check the new imports in lan_snapshot.py.
Database initialization creates the schema safely on the first run. The same function can run again because both statements preserve existing tables.
- Scroll to the end of the print_devices() function.
- Add this function directly below print_devices():
def initialize_database(connection: sqlite3.Connection) -> None:
connection.execute("CREATE TABLE IF NOT EXISTS scans (id INTEGER PRIMARY KEY, scanned_at TEXT NOT NULL, network TEXT NOT NULL)")
connection.execute("CREATE TABLE IF NOT EXISTS devices (scan_id INTEGER NOT NULL, ip_address TEXT NOT NULL, mac_address TEXT NOT NULL, PRIMARY KEY (scan_id, mac_address))")
connection.commit()
How does the schema work?
- The first SQL statement creates one row for every saved scan.
- The second statement stores the devices attached to a specific scan_id.
- The combined primary key prevents the same MAC address from appearing twice inside one scan.
- connection.commit() makes the schema changes durable.
- Save lan_snapshot.py.
Packet sending still needs administrator privileges. The terminal may request your Mac administrator password when you run the scanner.
- Switch back to the integrated terminal from earlier.
- Run the authorized scanner with the updated file by running:
sudo .venv/bin/python lan_snapshot.py
- Enter your administrator password if the terminal requests it.
- Enter your recorded IPv4 address plus subnet mask in the requested format.
You should still see the current LAN snapshot table. This confirms that the schema code loads without disrupting the ARP scan.
Scanner stopped before showing the table?
Compare both table definitions with the code above. A missing quote or parenthesis prevents Python from loading the file.
Help me fix the database schema code.
Save each snapshot
A saved snapshot needs the scan metadata plus every observed device. The connection context keeps those inserts in one transaction.
- Return to lan_snapshot.py.
- Place this function directly below initialize_database():
def save_snapshot(connection: sqlite3.Connection, network: IPv4Network, devices: list[Device]) -> tuple[int, str]:
scanned_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
with connection:
cursor = connection.execute("INSERT INTO scans (scanned_at, network) VALUES (?, ?)", (scanned_at, str(network)))
scan_id = cursor.lastrowid
if scan_id is None:
raise RuntimeError("SQLite did not return a scan ID.")
connection.executemany("INSERT INTO devices (scan_id, ip_address, mac_address) VALUES (?, ?, ?)", ((scan_id, device.ip_address, device.mac_address) for device in devices))
return scan_id, scanned_at
What does this function preserve?
- datetime.now(timezone.utc) creates a timezone-aware UTC value for the scan.
- isoformat(timespec="seconds") converts the timestamp into a consistent database string.
- The ? placeholders bind values separately from each query.
- cursor.lastrowid captures the ID assigned to the new scan.
- connection.executemany() attaches every discovered device to the same scan ID.
- Save lan_snapshot.py.
- Confirm save_snapshot() has no unresolved references in the editor.
Seeing a problem in save_snapshot?
Check that both query calls use parenthesized values after the SQL string. Confirm that return scan_id, scanned_at sits outside the connection context.
Help me check the snapshot transaction.
The persistence functions are ready. The scanner now needs to call them after printing the current device table.
- Find the existing main() function near the bottom of lan_snapshot.py.
- Select from def main() -> None: through print_devices("Current LAN snapshot", devices).
- Replace the selected function with this version:
def main() -> None:
network = prompt_for_network()
print(f"Scanning {network} with ARP...")
try:
devices = scan_network(network)
except OSError as error:
print(f"Scan failed: {error}")
print("Run this script with administrator privileges on macOS.")
return
print_devices("Current LAN snapshot", devices)
connection: sqlite3.Connection | None = None
try:
connection = sqlite3.connect(DB_PATH)
initialize_database(connection)
current_scan_id, scanned_at = save_snapshot(connection, network, devices)
print(f"\nSaved snapshot {current_scan_id} at {scanned_at}")
except sqlite3.Error as error:
print(f"Database operation failed: {error}")
finally:
if connection is not None:
connection.close()
How does main manage the database?
- sqlite3.connect(DB_PATH) opens the local database file.
- initialize_database() ensures the required tables exist.
- save_snapshot() stores the current scan plus its devices.
- The finally block closes the connection after success or failure.
- Save lan_snapshot.py.
Before you run the scanner, do you think the current device table can now survive after the process exits?
- Run the updated scanner with administrator privileges by running:
sudo .venv/bin/python lan_snapshot.py
- Enter your administrator password if the terminal requests it.
- Enter your recorded IPv4 address plus subnet mask.
You should see the current device table followed by a Saved snapshot line containing a scan ID plus UTC timestamp.
That is the memory layer working. Your scan now remains available after the Python process exits.
No saved snapshot message?
Look for the database failure line printed below the device table. Confirm that DB_PATH is set to lan_snapshots.db.
Help me diagnose the failed database save.
Query the saved history
The strongest persistence check happens in a separate process. A fresh command can read the row only if SQLite wrote it to lan_snapshots.db.
Before you run the query, do you expect the scan row to remain after the scanner has closed its connection?
- Query the saved scan history from the integrated terminal by running:
.venv/bin/python -m sqlite3 lan_snapshots.db "SELECT id, scanned_at, network FROM scans;"
You should see at least one row containing a scan ID, a UTC timestamp, and the normalized IPv4 network. That row proves the snapshot survived after the scanner exited.
Query did not show a saved row?
Confirm the terminal is still inside the lan-snapshot-logger folder. Check that lan_snapshots.db appears beside lan_snapshot.py.
Help me inspect the missing SQLite history.
✔️ Awesome, I've got everything!
Great work. Save lan_snapshot.py once more before moving on.
ⓧ I'd like to double check the full code
- Compare your complete lan_snapshot.py file with this checkpoint:
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from ipaddress import IPv4Address, IPv4Network
from scapy.layers.l2 import ARP, Ether
from scapy.sendrecv import srp
DB_PATH = "lan_snapshots.db"
BROADCAST_MAC = "ff:ff:ff:ff:ff:ff"
MAX_ADDRESSES = 256
SCAN_TIMEOUT_SECONDS = 2
@dataclass(frozen=True)
class Device:
ip_address: str
mac_address: str
def prompt_for_network() -> IPv4Network:
while True:
raw_value = input(
"Enter IPv4 address/subnet mask "
"(example: 192.168.1.23/255.255.255.0): "
).strip()
try:
network = IPv4Network(raw_value, strict=False)
except ValueError as error:
print(f"Invalid IPv4 network: {error}")
continue
if network.num_addresses > MAX_ADDRESSES:
print(
f"This learning scanner allows at most {MAX_ADDRESSES} "
"addresses per run. Enter an authorized /24 or smaller network."
)
continue
return network
def device_sort_key(device: Device) -> IPv4Address:
return IPv4Address(device.ip_address)
def scan_network(network: IPv4Network) -> list[Device]:
request = Ether(dst=BROADCAST_MAC) / ARP(pdst=str(network))
answered, _ = srp(request, timeout=SCAN_TIMEOUT_SECONDS, verbose=False)
devices_by_mac: dict[str, Device] = {}
for _, reply in answered:
arp_reply = reply[ARP]
device = Device(str(arp_reply.psrc), str(arp_reply.hwsrc).lower())
devices_by_mac[device.mac_address] = device
return sorted(devices_by_mac.values(), key=device_sort_key)
def print_devices(title: str, devices: list[Device]) -> None:
print(f"\n{title}")
if not devices:
print("No devices answered this ARP scan.")
return
print(f"{'IP address':<16} MAC address")
print(f"{'-' * 15:<16} {'-' * 17}")
for device in devices:
print(f"{device.ip_address:<16} {device.mac_address}")
print(f"Responding devices: {len(devices)}")
def initialize_database(connection: sqlite3.Connection) -> None:
connection.execute("CREATE TABLE IF NOT EXISTS scans (id INTEGER PRIMARY KEY, scanned_at TEXT NOT NULL, network TEXT NOT NULL)")
connection.execute("CREATE TABLE IF NOT EXISTS devices (scan_id INTEGER NOT NULL, ip_address TEXT NOT NULL, mac_address TEXT NOT NULL, PRIMARY KEY (scan_id, mac_address))")
connection.commit()
def save_snapshot(connection: sqlite3.Connection, network: IPv4Network, devices: list[Device]) -> tuple[int, str]:
scanned_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
with connection:
cursor = connection.execute("INSERT INTO scans (scanned_at, network) VALUES (?, ?)", (scanned_at, str(network)))
scan_id = cursor.lastrowid
if scan_id is None:
raise RuntimeError("SQLite did not return a scan ID.")
connection.executemany("INSERT INTO devices (scan_id, ip_address, mac_address) VALUES (?, ?, ?)", ((scan_id, device.ip_address, device.mac_address) for device in devices))
return scan_id, scanned_at
def main() -> None:
network = prompt_for_network()
print(f"Scanning {network} with ARP...")
try:
devices = scan_network(network)
except OSError as error:
print(f"Scan failed: {error}")
print("Run this script with administrator privileges on macOS.")
return
print_devices("Current LAN snapshot", devices)
connection: sqlite3.Connection | None = None
try:
connection = sqlite3.connect(DB_PATH)
initialize_database(connection)
current_scan_id, scanned_at = save_snapshot(connection, network, devices)
print(f"\nSaved snapshot {current_scan_id} at {scanned_at}")
except sqlite3.Error as error:
print(f"Database operation failed: {error}")
finally:
if connection is not None:
connection.close()
if __name__ == "__main__":
main()
Your scanner now preserves every run as a timestamped snapshot. Next, you will compare two saved snapshots to reveal what changed on your network.
Turn Snapshots into a Change Report
Your SQLite snapshots now survive after the scanner exits. However, understanding a change still requires you to inspect raw database rows.
This step loads saved devices into Python dictionaries keyed by MAC address. Set operations then separate new devices from missing devices.
In this step, get ready to:
- Load the previous snapshot into a dictionary keyed by MAC address.
- Classify devices with dictionary key set operations.
- Run two scans to produce a visible change report.
Load snapshots by MAC address
Each scan ID identifies one saved moment. The comparison needs the scan immediately before the current one.
- Find save_snapshot() in lan_snapshot.py.
- Add find_previous_scan_id() below save_snapshot() by pasting this function:
def find_previous_scan_id(
connection: sqlite3.Connection,
current_scan_id: int,
) -> int | None:
row = connection.execute(
"SELECT MAX(id) FROM scans WHERE id < ?",
(current_scan_id,),
).fetchone()
if row is None or row[0] is None:
return None
return int(row[0])
How Does This Find the Previous Scan?
- The query finds the largest scan ID that remains below the current scan ID.
- The placeholder binds current_scan_id without building SQL from string fragments.
- A return value of None means the database has no earlier snapshot.
- Save lan_snapshot.py.
- Confirm PyCharm shows no red syntax underline inside find_previous_scan_id().
Seeing a Syntax Warning?
Check the indentation beneath if row is None or row[0] is None:. Both return statements must remain inside find_previous_scan_id().
Ask for help with the function structure: Help me check the indentation in find_previous_scan_id().
Device rows become easier to compare when their MAC addresses act as dictionary keys. Each key points to the complete IP and MAC observation from that scan.
- Add load_snapshot() below find_previous_scan_id() by pasting this function:
def load_snapshot(
connection: sqlite3.Connection,
scan_id: int,
) -> dict[str, Device]:
rows = connection.execute(
"""
SELECT ip_address, mac_address
FROM devices
WHERE scan_id = ?
""",
(scan_id,),
).fetchall()
return {
mac_address: Device(ip_address, mac_address)
for ip_address, mac_address in rows
}
What Does This Function Return?
- The query loads every IP and MAC pair associated with one scan ID.
- The dictionary comprehension rebuilds each row as a Device object.
- The MAC address becomes the key because it lets the report match the same responding identity across two scans.
- Save lan_snapshot.py.
- Check that the scanner remains runnable by using this command in the integrated terminal:
sudo .venv/bin/python lan_snapshot.py
What Does This Check Prove?
This run checks that Python can parse both new function definitions. It also creates another timestamped snapshot for the comparison work ahead.
- Enter the same authorized IPv4 address and subnet mask you recorded earlier.
You'll see the current device table followed by a new Saved snapshot line.
Scanner Stopped Before Saving?
Check that both new functions sit above main(). Review the closing braces in the dictionary comprehension if Python reports a syntax problem.
Get help with the failing run: Help me diagnose why lan_snapshot.py stopped after I added the snapshot-loading functions.
Calculate network changes
A short display helper keeps each result category consistent. The same helper prints both new devices and missing devices.
- Add print_device_group() below load_snapshot() by pasting this function:
def print_device_group(label: str, devices: list[Device]) -> None:
print(f"{label}: {len(devices)}")
for device in devices:
print(f" {device.ip_address:<16} {device.mac_address}")
What Does This Helper Print?
The helper prints a category label with its device count. It places each matching IP and MAC pair on an indented row.
- Save lan_snapshot.py.
- Confirm the editor shows no red syntax underline inside print_device_group().
Helper Function Indented Incorrectly?
Keep for device in devices: inside the function. Indent its final print statement one additional level.
Ask for an indentation check: Help me fix the indentation in print_device_group().
Dictionary key views support intersection and difference operations. If you have used C++, picture associative containers keyed by MAC address with set reasoning applied to those keys.
- Add the calculation portion of print_changes() below print_device_group() by pasting this function:
def print_changes(
previous: dict[str, Device],
current: dict[str, Device],
) -> None:
previous_macs = previous.keys()
current_macs = current.keys()
shared_macs = previous_macs & current_macs
new_devices = sorted(
(current[mac] for mac in current_macs - previous_macs),
key=device_sort_key,
)
missing_devices = sorted(
(previous[mac] for mac in previous_macs - current_macs),
key=device_sort_key,
)
changed_addresses = sorted(
(
(previous[mac], current[mac])
for mac in shared_macs
if previous[mac].ip_address != current[mac].ip_address
),
key=lambda pair: device_sort_key(pair[1]),
)
unchanged_count = len(shared_macs) - len(changed_addresses)
How Are the Categories Calculated?
- The current-only keys identify new devices.
- The previous-only keys identify missing devices.
- The shared keys identify MAC addresses that answered both scans.
- A shared MAC address enters changed_addresses when its saved IP address differs between snapshots.
- Save lan_snapshot.py.
- Create another saved baseline while checking the calculation syntax by running:
sudo .venv/bin/python lan_snapshot.py
Why Create Another Baseline?
Each successful run adds another point in the network history. These nearby snapshots give the finished comparison logic two saved moments to evaluate.
- Enter the same authorized IPv4 address and subnet mask.
You'll see another device table followed by a distinct saved scan ID.
Seeing a Problem in the Set Logic?
Check the parentheses around each generator expression. Confirm that previous and current remain dictionaries throughout the function.
Get help with the collection logic: Help me debug the dictionary key operations in print_changes().
The calculated collections now need a readable terminal summary. Counts make a stable network easy to recognize even when a category contains no device rows.
- Find unchanged_count = len(shared_macs) - len(changed_addresses) inside print_changes().
- Add these output lines directly below it:
print("\nChanges since the previous snapshot")
print_device_group("New devices", new_devices)
print_device_group("Missing devices", missing_devices)
print(f"Unchanged MAC addresses: {unchanged_count}")
print(f"IP-changed devices: {len(changed_addresses)}")
for old_device, new_device in changed_addresses:
print(
f" {new_device.mac_address}: "
f"{old_device.ip_address} -> {new_device.ip_address}"
)
How Is the Report Presented?
The report always prints all four categories. IP changes include the old address followed by the new address for the same MAC key.
- Save lan_snapshot.py.
- Confirm every report line remains indented inside print_changes().
Report Lines Outside the Function?
Align the first report print with unchanged_count. Keep the final nested print beneath the for loop.
Ask for help with the output block: Help me check the report indentation in print_changes().
Connect comparison to each scan
The helper functions can now classify two snapshots. The final connection loads the previous scan after saving the current one.
- Find the saved snapshot print line inside the database try block in main().
- Replace that print line with this comparison block:
previous_scan_id = find_previous_scan_id(
connection,
current_scan_id,
)
print(f"\nSaved snapshot {current_scan_id} at {scanned_at}")
if previous_scan_id is None:
print("No previous snapshot exists yet. Run the scanner again later.")
else:
previous = load_snapshot(connection, previous_scan_id)
current = {device.mac_address: device for device in devices}
print_changes(previous, current)
How Does the Full Comparison Flow Work?
- The current snapshot is saved before the program looks for an earlier scan ID.
- The previous rows are loaded into a MAC-keyed dictionary.
- The current Device list becomes a matching dictionary.
- The comparison runs only when an earlier snapshot exists.
- Save lan_snapshot.py.
- Compare your file with the complete reference before the final scans.
✔️ Awesome, I've got everything!
Your scanner now saves each run before comparing it with the previous snapshot.
ⓧ I'd like to double check the full code
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from ipaddress import IPv4Address, IPv4Network
from scapy.layers.l2 import ARP, Ether
from scapy.sendrecv import srp
DB_PATH = "lan_snapshots.db"
BROADCAST_MAC = "ff:ff:ff:ff:ff:ff"
MAX_ADDRESSES = 256
SCAN_TIMEOUT_SECONDS = 2
@dataclass(frozen=True)
class Device:
ip_address: str
mac_address: str
def prompt_for_network() -> IPv4Network:
while True:
raw_value = input(
"Enter IPv4 address/subnet mask "
"(example: 192.168.1.23/255.255.255.0): "
).strip()
try:
network = IPv4Network(raw_value, strict=False)
except ValueError as error:
print(f"Invalid IPv4 network: {error}")
continue
if network.num_addresses > MAX_ADDRESSES:
print(
f"This learning scanner allows at most {MAX_ADDRESSES} "
"addresses per run. Enter an authorized /24 or smaller network."
)
continue
return network
def device_sort_key(device: Device) -> IPv4Address:
return IPv4Address(device.ip_address)
def scan_network(network: IPv4Network) -> list[Device]:
request = Ether(dst=BROADCAST_MAC) / ARP(pdst=str(network))
answered, _ = srp(
request,
timeout=SCAN_TIMEOUT_SECONDS,
verbose=False,
)
devices_by_mac: dict[str, Device] = {}
for _, reply in answered:
arp_reply = reply[ARP]
device = Device(
ip_address=str(arp_reply.psrc),
mac_address=str(arp_reply.hwsrc).lower(),
)
devices_by_mac[device.mac_address] = device
return sorted(devices_by_mac.values(), key=device_sort_key)
def print_devices(title: str, devices: list[Device]) -> None:
print(f"\n{title}")
if not devices:
print("No devices answered this ARP scan.")
return
print(f"{'IP address':<16} MAC address")
print(f"{'-' * 15:<16} {'-' * 17}")
for device in devices:
print(f"{device.ip_address:<16} {device.mac_address}")
print(f"Responding devices: {len(devices)}")
def initialize_database(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY,
scanned_at TEXT NOT NULL,
network TEXT NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS devices (
scan_id INTEGER NOT NULL,
ip_address TEXT NOT NULL,
mac_address TEXT NOT NULL,
PRIMARY KEY (scan_id, mac_address)
)
"""
)
connection.commit()
def save_snapshot(
connection: sqlite3.Connection,
network: IPv4Network,
devices: list[Device],
) -> tuple[int, str]:
scanned_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
with connection:
cursor = connection.execute(
"INSERT INTO scans (scanned_at, network) VALUES (?, ?)",
(scanned_at, str(network)),
)
scan_id = cursor.lastrowid
if scan_id is None:
raise RuntimeError("SQLite did not return a scan ID.")
connection.executemany(
"""
INSERT INTO devices (scan_id, ip_address, mac_address)
VALUES (?, ?, ?)
""",
(
(scan_id, device.ip_address, device.mac_address)
for device in devices
),
)
return scan_id, scanned_at
def find_previous_scan_id(
connection: sqlite3.Connection,
current_scan_id: int,
) -> int | None:
row = connection.execute(
"SELECT MAX(id) FROM scans WHERE id < ?",
(current_scan_id,),
).fetchone()
if row is None or row[0] is None:
return None
return int(row[0])
def load_snapshot(
connection: sqlite3.Connection,
scan_id: int,
) -> dict[str, Device]:
rows = connection.execute(
"""
SELECT ip_address, mac_address
FROM devices
WHERE scan_id = ?
""",
(scan_id,),
).fetchall()
return {
mac_address: Device(ip_address, mac_address)
for ip_address, mac_address in rows
}
def print_device_group(label: str, devices: list[Device]) -> None:
print(f"{label}: {len(devices)}")
for device in devices:
print(f" {device.ip_address:<16} {device.mac_address}")
def print_changes(
previous: dict[str, Device],
current: dict[str, Device],
) -> None:
previous_macs = previous.keys()
current_macs = current.keys()
shared_macs = previous_macs & current_macs
new_devices = sorted(
(current[mac] for mac in current_macs - previous_macs),
key=device_sort_key,
)
missing_devices = sorted(
(previous[mac] for mac in previous_macs - current_macs),
key=device_sort_key,
)
changed_addresses = sorted(
(
(previous[mac], current[mac])
for mac in shared_macs
if previous[mac].ip_address != current[mac].ip_address
),
key=lambda pair: device_sort_key(pair[1]),
)
unchanged_count = len(shared_macs) - len(changed_addresses)
print("\nChanges since the previous snapshot")
print_device_group("New devices", new_devices)
print_device_group("Missing devices", missing_devices)
print(f"Unchanged MAC addresses: {unchanged_count}")
print(f"IP-changed devices: {len(changed_addresses)}")
for old_device, new_device in changed_addresses:
print(
f" {new_device.mac_address}: "
f"{old_device.ip_address} -> {new_device.ip_address}"
)
def main() -> None:
network = prompt_for_network()
print(f"Scanning {network} with ARP...")
try:
devices = scan_network(network)
except OSError as error:
print(f"Scan failed: {error}")
print("Run this script with administrator privileges on macOS.")
return
print_devices("Current LAN snapshot", devices)
connection: sqlite3.Connection | None = None
try:
connection = sqlite3.connect(DB_PATH)
initialize_database(connection)
current_scan_id, scanned_at = save_snapshot(
connection,
network,
devices,
)
previous_scan_id = find_previous_scan_id(
connection,
current_scan_id,
)
print(f"\nSaved snapshot {current_scan_id} at {scanned_at}")
if previous_scan_id is None:
print("No previous snapshot exists yet. Run the scanner again later.")
else:
previous = load_snapshot(connection, previous_scan_id)
current = {device.mac_address: device for device in devices}
print_changes(previous, current)
except sqlite3.Error as error:
print(f"Database operation failed: {error}")
finally:
if connection is not None:
connection.close()
if __name__ == "__main__":
main()
What Should Match?
Use this reference to align the helper functions and the updated database block inside main(). The final file preserves the existing scan error handling and connection cleanup.
- Start the first completed comparison by running this command:
sudo .venv/bin/python lan_snapshot.py
What Happens During This Run?
The scanner discovers current responders before saving a new snapshot. It then compares that snapshot with the scan that has the next-lowest ID.
- Enter the same authorized IPv4 address and subnet mask.
You'll see Changes since the previous snapshot followed by counts for new devices, missing devices, unchanged MAC addresses, and IP-changed devices.
Change Report Missing?
Confirm that previous_scan_id is calculated after save_snapshot(). Check that the call to print_changes() remains inside the else block.
Get help tracing the comparison path: Help me find why my saved scan does not print a change report.
The second scan works with every device online. Temporarily disconnecting a device you own gives the missing-device category a visible case to classify.
- Choose whether to put one device you own into airplane mode.
Before the second run, which result category do you expect to change?
- Start the second comparison by running this command:
sudo .venv/bin/python lan_snapshot.py
What Is Being Compared?
This run saves another complete snapshot. The report compares it only with the immediately preceding saved scan.
- Enter the same authorized IPv4 address and subnet mask.
You'll see all four result categories beneath Changes since the previous snapshot. A device that stopped answering appears under Missing devices.
The two distinct Saved snapshot IDs confirm that both raw scans remain separate database records. Your scanner now turns that history into an immediate network change report.
Secret mission
Secret Mission: Export the Latest Snapshot to CSV
Turn the newest saved network snapshot into a spreadsheet-friendly CSV file. Your exporter will preserve the scan details while making each discovered device easy to inspect or share.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your local project, pause your work, or delete it entirely. This project creates no paid or cloud resources, so leaving the files on your Mac has no ongoing service cost.
Resources you used:
- The local lan-snapshot-logger project folder.
- The complete scanner in lan_snapshot.py.
- The SQLite database lan_snapshots.db containing your network history.
- The isolated Python environment in .venv containing Scapy 2.7.0.
- The CSV exporter in export_latest.py.
- The newest spreadsheet-friendly export in latest_snapshot.csv.
Keep everything running
No action is needed. Choose this option if you want to keep scanning your authorized network or reviewing its saved history.
- Keep the lan-snapshot-logger folder in its current location.
- Protect lan_snapshots.db because it contains device identifiers from your authorized network.
- Protect latest_snapshot.csv for the same reason.
- Renew your JetBrains Student Pack each year if you continue using the student subscription.
New observations are collected only when you run the scanner. Keeping the files does not leave a scan running in the background.
Pause - I'll come back to this later
Close your editor to stop working while keeping every local artifact ready for your next session.
- Save any open files in PyCharm.
- Close PyCharm.
- Leave the lan-snapshot-logger folder in its current location.
Nothing in this project runs in the background. Your saved snapshots remain available when you return.
Delete - I don't want to use this again
This option permanently removes the local project so you can start fresh. Confirming the folder name first keeps the deletion scoped to this project.
- Close PyCharm.
- Use Finder to locate the lan-snapshot-logger folder.
- Confirm the selected folder is named lan-snapshot-logger.
- Remove lan-snapshot-logger in Finder.
- Verify that any macOS administrator request applies to lan-snapshot-logger.
- Approve any administrator request after that check.
- Confirm lan-snapshot-logger no longer appears in its original Finder location.
What does folder deletion remove?
Deleting lan-snapshot-logger removes lan_snapshot.py, lan_snapshots.db, .venv, export_latest.py, and latest_snapshot.csv together.
A macOS approval request can appear because the privileged scan may have created a root-owned file. The folder-name check keeps that approval limited to the project.
PyCharm and JetBrains Toolbox remain installed for other projects.
Nice Work!
Nice Work!
That's a wrap! Your Python LAN Snapshot Logger now turns authorized ARP responses into a durable SQLite history. It also reports how the latest scan differs from the one before it.
What you learned:
- Discover responding devices on an authorized local IPv4 network through Scapy packets that combine an Ethernet broadcast with an ARP request.
- Preserve timestamped snapshots in SQLite through parameterized SQL queries committed as one transaction.
- Compare snapshots to classify each MAC address as new, missing, unchanged, or re-addressed through Python dictionaries plus set operations.
- Complete the optional Secret Mission by exporting the newest saved snapshot as a spreadsheet-friendly CSV file with one sorted row per device.
Ready to quiz yourself?