-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathscanner.py
More file actions
376 lines (299 loc) · 11.9 KB
/
Copy pathscanner.py
File metadata and controls
376 lines (299 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
"""
WiFi scanner for macOS.
Wraps `sudo wdutil info` and parses the WIFI section to extract
the currently connected network's signal data.
Why wdutil?
- The classic `airport` binary was removed in macOS 14.4+
- `system_profiler SPAirPortDataType` redacts BSSIDs on modern macOS
- `ioreg` returns all-zero BSSIDs (Apple privacy lockdown)
- `wdutil info` is the only reliable way to get full data, but needs sudo
Usage:
scanner = WifiScanner(sudo_password="...")
sample = scanner.scan()
# -> {'ssid': 'Home', 'bssid': 'aa:bb:...', 'rssi': -45, ...}
"""
from __future__ import annotations
import re
import subprocess
import time
import urllib.request
from dataclasses import dataclass, asdict
from typing import Optional, Protocol, runtime_checkable
# Fields we care about from the WIFI section of `wdutil info`.
# Mapping wdutil-key -> our internal name. We coerce types in parse_wifi_block.
WIFI_FIELDS = {
"SSID": "ssid",
"BSSID": "bssid",
"RSSI": "rssi", # int (dBm)
"Noise": "noise", # int (dBm)
"Channel": "channel", # str like "5g40/80"
"PHY Mode": "phy_mode", # str like "11ac"
"Tx Rate": "tx_rate", # float (Mbps)
"Security": "security",
"MCS Index": "mcs_index", # int
}
@dataclass
class WifiSample:
"""A single WiFi measurement at a point in time."""
ssid: Optional[str] = None
bssid: Optional[str] = None
rssi: Optional[int] = None
noise: Optional[int] = None
channel: Optional[str] = None
phy_mode: Optional[str] = None
tx_rate: Optional[float] = None
security: Optional[str] = None
mcs_index: Optional[int] = None
def to_dict(self):
return asdict(self)
@property
def is_valid(self) -> bool:
"""A sample is valid if we got at least an RSSI reading."""
return self.rssi is not None
class ScanError(Exception):
"""Raised when wdutil fails or its output can't be parsed."""
# ── Scanner Protocol ────────────────────────────────────────────
@runtime_checkable
class Scanner(Protocol):
"""Contract for all scanner implementations."""
def scan(self) -> WifiSample: ...
def verify_credentials(self) -> bool: ...
def force_reconnect(self) -> None: ...
# ── Speed test ──────────────────────────────────────────────────
SPEED_TEST_URL = "https://speed.cloudflare.com/__down?bytes=100000000" # 100 MB
SPEED_TEST_TIMEOUT = 60
def speed_test(url: str = SPEED_TEST_URL, timeout: float = SPEED_TEST_TIMEOUT) -> float:
"""Download a test file and return speed in Mbps."""
start = time.time()
req = urllib.request.Request(url, headers={"User-Agent": "wifi-heatmap"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read()
elapsed = time.time() - start
if elapsed <= 0:
return 0.0
return round((len(data) * 8) / (elapsed * 1_000_000), 1)
# ── Local scanner ───────────────────────────────────────────────
class WifiScanner:
"""Wraps `sudo wdutil info` for repeated scanning."""
def __init__(self, sudo_password: str, timeout: float = 10.0):
self.sudo_password = sudo_password
self.timeout = timeout
def _run_wdutil(self) -> str:
"""Execute `sudo -S wdutil info` and return stdout text."""
try:
# -S reads password from stdin; -p '' suppresses the password prompt
result = subprocess.run(
["sudo", "-S", "-p", "", "wdutil", "info"],
input=self.sudo_password + "\n",
capture_output=True,
text=True,
timeout=self.timeout,
)
except subprocess.TimeoutExpired:
raise ScanError(f"wdutil timed out after {self.timeout}s")
except FileNotFoundError:
raise ScanError(
"wdutil not found. This tool only works on macOS."
)
if result.returncode != 0:
stderr = result.stderr.strip()
if "incorrect password" in stderr.lower() or "Sorry" in stderr:
raise ScanError("Incorrect sudo password")
raise ScanError(f"wdutil failed: {stderr or 'unknown error'}")
return result.stdout
def scan(self) -> WifiSample:
"""Run a single scan and return the parsed sample."""
output = self._run_wdutil()
return parse_wdutil_output(output)
def force_reconnect(self) -> None:
"""Toggle WiFi off/on to force the laptop to pick the nearest AP."""
try:
subprocess.run(
["networksetup", "-setairportpower", "en0", "off"],
capture_output=True, text=True, timeout=5,
)
time.sleep(1)
subprocess.run(
["networksetup", "-setairportpower", "en0", "on"],
capture_output=True, text=True, timeout=5,
)
time.sleep(3)
except Exception:
raise ScanError("Failed to toggle WiFi for force-reconnect")
def verify_credentials(self) -> bool:
"""Try a scan to verify the sudo password works. Raises ScanError if not."""
sample = self.scan()
if not sample.is_valid:
raise ScanError(
"wdutil ran but no RSSI was found - is WiFi connected?"
)
return True
# ── Remote scanner ──────────────────────────────────────────────
class RemoteWifiScanner:
"""Fetches WiFi data from a remote agent instead of local wdutil."""
def __init__(self, agent_url: str, timeout: float = 10.0):
url = agent_url.strip().rstrip("/")
if not url.startswith("http"):
url = f"http://{url}"
if url.count(":") < 2: # no port specified
url = f"{url}:5555"
self.agent_url = url
self.timeout = timeout
def scan(self) -> WifiSample:
import json
url = f"{self.agent_url}/scan"
req = urllib.request.Request(url)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
data = json.loads(resp.read())
except Exception as e:
raise ScanError(f"Remote agent error: {e}")
if "error" in data:
raise ScanError(data["error"])
return WifiSample(
ssid=data.get("ssid"),
bssid=data.get("bssid"),
rssi=data.get("rssi"),
noise=data.get("noise"),
channel=data.get("channel"),
phy_mode=data.get("phy_mode"),
tx_rate=data.get("tx_rate"),
security=data.get("security"),
mcs_index=data.get("mcs_index"),
)
def verify_credentials(self) -> bool:
sample = self.scan()
if not sample.is_valid:
raise ScanError("Remote agent returned no RSSI")
return True
def force_reconnect(self) -> None:
pass # not supported on remote — agent stays connected
# ── Parsing ─────────────────────────────────────────────────────
def parse_wdutil_output(text: str) -> WifiSample:
"""
Parse the full `wdutil info` output, extract the WIFI section,
and return a WifiSample with whatever fields we found.
"""
wifi_block = extract_wifi_block(text)
if not wifi_block:
raise ScanError("Could not find WIFI section in wdutil output")
return parse_wifi_block(wifi_block)
def extract_wifi_block(text: str) -> str:
"""
Pull out just the WIFI section between the dashes.
wdutil output looks like:
————...————
NETWORK
————...————
...network fields...
————...————
WIFI
————...————
...wifi fields...
————...————
BLUETOOTH
...
We find "WIFI" on its own line, then capture lines until the next
section divider (a line of em-dashes) followed by an ALL-CAPS header.
"""
lines = text.splitlines()
# Find the WIFI header
wifi_start = None
for i, line in enumerate(lines):
if line.strip() == "WIFI":
wifi_start = i
break
if wifi_start is None:
return ""
# Skip past the closing divider after WIFI header
# (lines[wifi_start+1] should be the dashes line)
content_start = wifi_start + 2
# Collect lines until we hit the next section.
# A new section is signaled by: a divider line, then an ALL-CAPS header.
collected = []
i = content_start
while i < len(lines):
line = lines[i]
# Check if this is a divider followed by a section header
if _is_divider(line) and i + 1 < len(lines):
next_line = lines[i + 1].strip()
if next_line and next_line.isupper() and next_line != "WIFI":
break
collected.append(line)
i += 1
return "\n".join(collected)
def _is_divider(line: str) -> bool:
"""True if line is a row of em-dashes used as section divider."""
stripped = line.strip()
if len(stripped) < 10:
return False
# The dividers in wdutil output are em-dashes (U+2014)
return all(c == "\u2014" for c in stripped)
def parse_wifi_block(block: str) -> WifiSample:
"""
Parse a `key : value` block (the WIFI section) into a WifiSample.
Each line looks like:
' RSSI : -44 dBm'
' SSID : MyNetwork'
' Tx Rate : 526.0 Mbps'
Some values have units we strip; some are continuation lines
(indented further, no key) which we ignore.
"""
sample = WifiSample()
for raw_line in block.splitlines():
line = raw_line.strip()
if not line or ":" not in line:
continue
# Continuation lines start with the value directly (no key).
# These look like: " : 192.168.68.1"
# Skip them.
if line.startswith(":"):
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if key not in WIFI_FIELDS:
continue
attr = WIFI_FIELDS[key]
coerced = _coerce_value(attr, value)
if coerced is not None:
setattr(sample, attr, coerced)
return sample
def _coerce_value(attr: str, raw: str):
"""Convert raw string value into the right type, stripping units."""
if not raw:
return None
if attr == "rssi" or attr == "noise":
# "-44 dBm" -> -44
m = re.search(r"-?\d+", raw)
return int(m.group()) if m else None
if attr == "tx_rate":
# "526.0 Mbps" -> 526.0
m = re.search(r"-?\d+(?:\.\d+)?", raw)
return float(m.group()) if m else None
if attr == "mcs_index":
m = re.search(r"\d+", raw)
return int(m.group()) if m else None
# Everything else stays as a string
return raw
# CLI for testing the scanner standalone
if __name__ == "__main__":
import getpass
import json
import sys
print("WiFi Scanner Test")
print("-" * 40)
pw = getpass.getpass("Enter your sudo password: ")
scanner = WifiScanner(sudo_password=pw)
try:
sample = scanner.scan()
except ScanError as e:
print(f"\nERROR: {e}", file=sys.stderr)
sys.exit(1)
print("\nParsed WiFi sample:")
print(json.dumps(sample.to_dict(), indent=2))
if sample.is_valid:
print(f"\n✓ Got valid RSSI: {sample.rssi} dBm")
else:
print("\n✗ No RSSI captured - check that WiFi is connected")
sys.exit(1)