Herramienta en Python que permite analizar los dispositivos inalámbricos que el sistema operativo es capaz de identificar mediante Wi-Fi y Bluetooth.
La idea es disponer de una herramienta sencilla que permita recopilar información de los dispositivos detectados, identificar fabricantes y aplicar una serie de firmas para realizar una primera clasificación.
El proyecto está pensado como una base sobre la que seguir incorporando nuevas técnicas de análisis.
Estructura del proyecto
El proyecto está formado por cuatro archivos principales:
WirelessScope/
├── wirelessscope.py
├── signatures.json
├── requirements.txt
├── README.md
└── run.sh
La aplicación utiliza Python y las herramientas que proporciona macOS, por lo que no es necesario instalar numerosas dependencias externas.
Ejecución
Para ejecutar la aplicación:
python3 wirelessscope.py
También he preparado un pequeño script:
./run.sh
La aplicación abre una interfaz gráfica desde la que se puede iniciar un escaneo.
Escaneo Wi-Fi
Para obtener información de las redes Wi-Fi visibles utilizo las herramientas disponibles en macOS.
El programa ejecuta:
raw = run_command([
"system_profiler",
"SPAirPortDataType",
"-detailLevel",
"full"
])
A partir de la información devuelta intento obtener datos como:
SSID
BSSID
Canal
Señal
Seguridad
Los datos se transforman posteriormente en objetos Device.
Device(
technology="Wi-Fi",
name=d.get("name") or "(oculto/desconocido)",
identifier=d.get("identifier") or "-",
manufacturer=manufacturer_from_mac(
d.get("identifier", "")
),
signal=d.get("signal") or "-",
channel=d.get("channel") or "-",
security=d.get("security") or "-",
category=category,
confidence=confidence,
note=note,
timestamp=now
)
Escaneo Bluetooth
Para Bluetooth utilizo igualmente información proporcionada por macOS:
raw = run_command([
"system_profiler",
"SPBluetoothDataType"
])
El programa intenta localizar información sobre los dispositivos Bluetooth visibles y extraer:
Nombre
Identificador
RSSI
Posteriormente se aplica el mismo sistema de clasificación utilizado para Wi-Fi.
Modelo de dispositivo
Para mantener organizada la información utilizo un dataclass:
@dataclass
class Device:
technology: str
name: str
identifier: str
manufacturer: str
signal: str
channel: str
security: str
category: str
confidence: int
note: str
timestamp: str
De esta forma cada dispositivo detectado mantiene una estructura homogénea independientemente de que proceda de Wi-Fi o Bluetooth.
Identificación del fabricante
También he incluido una pequeña tabla OUI para realizar una identificación básica del fabricante a partir de la dirección MAC.
def manufacturer_from_mac(mac: str) -> str:
prefix = re.sub(
r"[^0-9A-Fa-f]",
"",
mac
)[:6].upper()
oui = {
"001A11": "Google",
"A4C138": "Apple",
"ACBC32": "Apple",
"D8A011": "Amazon",
"0017F2": "DJI",
"6077E2": "DJI",
"7C2A31": "Samsung",
"8C7712": "Xiaomi",
"D850E6": "Huawei"
}
return oui.get(prefix, "Desconocido")
Esta tabla es deliberadamente pequeña en esta primera versión. Una de las mejoras que quiero incorporar es utilizar una base OUI mucho más completa.
Sistema de firmas
Para no tener que modificar el código cada vez que quiera añadir un nuevo dispositivo he separado las firmas en un archivo:
signatures.json
Por ejemplo:
{
"match": ["airtag"],
"category": "Posible AirTag / rastreador",
"confidence": 90,
"note": "Nombre coincidente con AirTag."
}
Otra firma:
{
"match": ["dji", "mavic", "phantom", "mini"],
"category": "Posible dispositivo DJI / dron",
"confidence": 70,
"note": "Coincidencia con patrones DJI."
}
El programa busca coincidencias en la información disponible:
def classify(text: str, technology: str):
text_l = text.lower()
best = (
"Desconocido",
0,
"Sin coincidencia en la base local."
)
for rule in SIGNATURES.get(technology, []):
haystack = " ".join(
str(text_l).split()
)
matches = [
x.lower()
for x in rule.get("match", [])
]
if any(
m and m in haystack
for m in matches
):
confidence = int(
rule.get("confidence", 60)
)
if confidence > best[1]:
best = (
rule.get(
"category",
"Dispositivo"
),
confidence,
rule.get("note", "")
)
return best
Confianza
No quiero que la aplicación considere que una coincidencia es una identificación absoluta.
Por eso cada regla incorpora un porcentaje de confianza:
AirTag
90 %
Tile
85 %
SmartTag
85 %
Meta/Ray-Ban
75 %
DJI
70 %
El resultado puede aparecer, por ejemplo, como:
Bluetooth
AirTag
Apple
-48 dBm
Posible AirTag / rastreador
90 %
La clasificación es únicamente una hipótesis basada en los datos observables.
Historial
También guardo las observaciones realizadas durante los escaneos.
El archivo utilizado es:
~/.fieldwatch/history.jsonl
Cada línea contiene un dispositivo:
with HISTORY_FILE.open(
"a",
encoding="utf-8"
) as f:
for d in devices:
f.write(
json.dumps(
asdict(d),
ensure_ascii=False
) + "\n"
)
Esto permite posteriormente analizar la evolución de los dispositivos detectados.
Escaneo periódico
La aplicación puede realizar nuevos escaneos sin bloquear la interfaz gráfica.
Para ello utilizo un hilo:
threading.Thread(
target=self._scan_worker,
daemon=True
).start()
El escaneo se realiza en segundo plano y posteriormente actualizo la interfaz mediante:
self.after(
0,
lambda: self._scan_done(devices)
)
De esta manera la interfaz continúa respondiendo mientras se realiza el análisis.
Exportación a CSV
También he añadido una opción para exportar los resultados.
with path.open(
"w",
newline="",
encoding="utf-8"
) as f:
writer = csv.DictWriter(
f,
fieldnames=list(
asdict(self.devices[0]).keys()
)
)
writer.writeheader()
for d in self.devices:
writer.writerow(
asdict(d)
)
Esto permite continuar el análisis posteriormente con Python, pandas, Excel u otras herramientas.
Interfaz
La interfaz está realizada utilizando tkinter, por lo que no necesito incorporar un framework gráfico adicional.
La tabla muestra:
Tecnología
Nombre/SSID
Identificador
Fabricante
Señal
Canal
Clasificación
Confianza
Además, al seleccionar un dispositivo puedo consultar información adicional sobre él.
Dependencias
El archivo requirements.txt está prácticamente vacío porque la aplicación utiliza principalmente la biblioteca estándar:
tkinter
json
csv
datetime
pathlib
subprocess
threading
dataclasses
En macOS, tkinter depende de cómo se haya instalado Python. Si la instalación de Python no incluye Tk, será necesario utilizar una distribución que lo incorpore.
Código completo
El núcleo de la aplicación se encuentra en wirelessscope.py.
#!/usr/bin/env python3
import csv
import datetime as dt
import json
import re
import subprocess
import threading
import tkinter as tk
from dataclasses import dataclass, asdict
from pathlib import Path
from tkinter import ttk, messagebox
APP_NAME = "WirelessScope"
DATA_DIR = Path.home() / ".wirelessscope"
HISTORY_FILE = DATA_DIR / "history.jsonl"
SIGNATURES_FILE = Path(__file__).with_name(
"signatures.json"
)
@dataclass
class Device:
technology: str
name: str
identifier: str
manufacturer: str
signal: str
channel: str
security: str
category: str
confidence: int
note: str
timestamp: str
def run_command(args):
try:
process = subprocess.run(
args,
capture_output=True,
text=True,
timeout=15
)
return process.stdout
except Exception:
return ""
def load_signatures():
try:
return json.loads(
SIGNATURES_FILE.read_text(
encoding="utf-8"
)
)
except Exception:
return {
"wifi": [],
"bluetooth": []
}
SIGNATURES = load_signatures()
def classify(text, technology):
text_l = text.lower()
best = (
"Desconocido",
0,
"Sin coincidencia."
)
for rule in SIGNATURES.get(
technology,
[]
):
matches = [
x.lower()
for x in rule.get(
"match",
[]
)
]
if any(
item in text_l
for item in matches
):
confidence = int(
rule.get(
"confidence",
60
)
)
if confidence > best[1]:
best = (
rule.get(
"category",
"Dispositivo"
),
confidence,
rule.get(
"note",
""
)
)
return best
def manufacturer_from_mac(mac):
prefix = re.sub(
r"[^0-9A-Fa-f]",
"",
mac
)[:6].upper()
oui = {
"001A11": "Google",
"A4C138": "Apple",
"ACBC32": "Apple",
"D8A011": "Amazon",
"0017F2": "DJI",
"6077E2": "DJI",
"7C2A31": "Samsung",
"8C7712": "Xiaomi",
"D850E6": "Huawei"
}
return oui.get(
prefix,
"Desconocido"
)
def scan_wifi():
devices = []
raw = run_command([
"system_profiler",
"SPAirPortDataType",
"-detailLevel",
"full"
])
if not raw:
return devices
current = {}
for line in raw.splitlines():
value = line.strip()
if value.startswith(
"SSID:"
):
if current.get("name"):
devices.append(
current
)
current = {
"name":
value.split(
":",
1
)[1].strip(),
"identifier": "",
"signal": "",
"channel": "",
"security": ""
}
elif value.startswith(
"BSSID:"
):
current["identifier"] = (
value.split(
":",
1
)[1].strip()
)
elif value.startswith(
"Channel:"
):
current["channel"] = (
value.split(
":",
1
)[1].strip()
)
elif value.startswith(
"Signal / Noise:"
):
current["signal"] = (
value.split(
":",
1
)[1].strip()
)
elif value.startswith(
"Security:"
):
current["security"] = (
value.split(
":",
1
)[1].strip()
)
if current.get("name"):
devices.append(current)
result = []
timestamp = dt.datetime.now().isoformat(
timespec="seconds"
)
for device in devices:
category, confidence, note = classify(
f"{device.get('name', '')} "
f"{device.get('identifier', '')}",
"wifi"
)
result.append(
Device(
technology="Wi-Fi",
name=device.get(
"name",
"(desconocido)"
),
identifier=device.get(
"identifier",
"-"
),
manufacturer=manufacturer_from_mac(
device.get(
"identifier",
""
)
),
signal=device.get(
"signal",
"-"
),
channel=device.get(
"channel",
"-"
),
security=device.get(
"security",
"-"
),
category=category,
confidence=confidence,
note=note,
timestamp=timestamp
)
)
return result
def scan_bluetooth():
devices = []
raw = run_command([
"system_profiler",
"SPBluetoothDataType"
])
if not raw:
return devices
timestamp = dt.datetime.now().isoformat(
timespec="seconds"
)
current = None
for line in raw.splitlines():
value = line.strip()
if not value:
continue
if (
value.endswith(":")
and len(value) < 100
):
name = value[:-1].strip()
if current:
devices.append(
current
)
current = {
"name": name,
"identifier": "",
"signal": ""
}
if current:
lower = value.lower()
if (
"address" in lower
and ":" in value
):
current[
"identifier"
] = value.split(
":",
1
)[1].strip()
if (
"rssi" in lower
and ":" in value
):
current[
"signal"
] = value.split(
":",
1
)[1].strip()
if current:
devices.append(
current
)
result = []
for device in devices:
name = device.get(
"name",
""
)
identifier = device.get(
"identifier",
""
)
category, confidence, note = classify(
f"{name} {identifier}",
"bluetooth"
)
result.append(
Device(
technology="Bluetooth",
name=name,
identifier=identifier or "-",
manufacturer="",
signal=device.get(
"signal",
"-"
),
channel="-",
security="-",
category=category,
confidence=confidence,
note=note,
timestamp=timestamp
)
)
return result
def scan_all():
return (
scan_wifi()
+
scan_bluetooth()
)
def save_history(devices):
DATA_DIR.mkdir(
parents=True,
exist_ok=True
)
with HISTORY_FILE.open(
"a",
encoding="utf-8"
) as file:
for device in devices:
file.write(
json.dumps(
asdict(device),
ensure_ascii=False
)
+
"\n"
)
class Application(tk.Tk):
def __init__(self):
super().__init__()
self.title(
APP_NAME
)
self.geometry(
"1280x720"
)
self.devices = []
self.scanning = False
self.only_classified = (
tk.BooleanVar(
value=False
)
)
self.status = (
tk.StringVar(
value="Listo"
)
)
self.create_interface()
def create_interface(self):
header = ttk.Frame(
self,
padding=10
)
header.pack(
fill="x"
)
ttk.Label(
header,
text=APP_NAME,
font=(
"Helvetica",
20,
"bold"
)
).pack(
side="left"
)
ttk.Button(
header,
text="Escanear",
command=self.start_scan
).pack(
side="right"
)
ttk.Button(
header,
text="Exportar CSV",
command=self.export_csv
).pack(
side="right",
padx=5
)
controls = ttk.Frame(
self,
padding=10
)
controls.pack(
fill="x"
)
ttk.Checkbutton(
controls,
text="Solo clasificados",
variable=self.only_classified,
command=self.refresh
).pack(
side="left"
)
ttk.Label(
controls,
textvariable=self.status
).pack(
side="right"
)
columns = (
"technology",
"name",
"identifier",
"manufacturer",
"signal",
"category",
"confidence"
)
self.table = ttk.Treeview(
self,
columns=columns,
show="headings"
)
titles = {
"technology": "Tecnología",
"name": "Nombre",
"identifier": "Identificador",
"manufacturer": "Fabricante",
"signal": "Señal",
"category": "Clasificación",
"confidence": "Confianza"
}
for column in columns:
self.table.heading(
column,
text=titles[column]
)
self.table.pack(
fill="both",
expand=True,
padx=10,
pady=10
)
self.table.bind(
"<<TreeviewSelect>>",
self.show_details
)
self.details = tk.StringVar(
value="Selecciona un dispositivo."
)
ttk.Label(
self,
textvariable=self.details,
padding=10
).pack(
fill="x"
)
def start_scan(self):
if self.scanning:
return
self.scanning = True
self.status.set(
"Escaneando..."
)
threading.Thread(
target=self.worker,
daemon=True
).start()
def worker(self):
try:
devices = scan_all()
save_history(
devices
)
self.after(
0,
lambda:
self.scan_finished(
devices
)
)
except Exception as error:
self.after(
0,
lambda:
messagebox.showerror(
APP_NAME,
str(error)
)
)
def scan_finished(
self,
devices
):
self.scanning = False
self.devices = devices
self.refresh()
self.status.set(
f"{len(devices)} dispositivos detectados"
)
def refresh(self):
for item in self.table.get_children():
self.table.delete(item)
for index, device in enumerate(
self.devices
):
if (
self.only_classified.get()
and device.confidence < 50
):
continue
self.table.insert(
"",
"end",
iid=str(index),
values=(
device.technology,
device.name,
device.identifier,
device.manufacturer,
device.signal,
device.category,
f"{device.confidence}%"
)
)
def show_details(
self,
_event=None
):
selected = (
self.table.selection()
)
if not selected:
return
device = self.devices[
int(selected[0])
]
self.details.set(
f"{device.technology} | "
f"{device.name} | "
f"{device.identifier} | "
f"Fabricante: "
f"{device.manufacturer or 'desconocido'} | "
f"Señal: {device.signal} | "
f"Clasificación: "
f"{device.category} | "
f"Confianza: "
f"{device.confidence}% | "
f"{device.note}"
)
def export_csv(self):
if not self.devices:
messagebox.showinfo(
APP_NAME,
"No hay datos para exportar."
)
return
DATA_DIR.mkdir(
parents=True,
exist_ok=True
)
filename = (
DATA_DIR
/
f"scan_{dt.datetime.now():%Y%m%d_%H%M%S}.csv"
)
with filename.open(
"w",
newline="",
encoding="utf-8"
) as file:
writer = csv.DictWriter(
file,
fieldnames=list(
asdict(
self.devices[0]
).keys()
)
)
writer.writeheader()
for device in self.devices:
writer.writerow(
asdict(device)
)
messagebox.showinfo(
APP_NAME,
f"CSV guardado en:\n{filename}"
)
if __name__ == "__main__":
Application().mainloop()