ap_scanner/utils.py (6830B)
1 """Utility functions: channel/frequency mapping, privilege checks, interface detection."""
2
3 import os
4 import shutil
5 import subprocess
6 from pathlib import Path
7
8 # 2.4 GHz channels (1-14)
9 CHANNELS_2G = list(range(1, 15))
10
11 # 5 GHz channels (common UNII bands)
12 CHANNELS_5G = [
13 36, 40, 44, 48, # UNII-1
14 52, 56, 60, 64, # UNII-2
15 100, 104, 108, 112, # UNII-2 Extended
16 116, 120, 124, 128,
17 132, 136, 140, 144,
18 149, 153, 157, 161, # UNII-3
19 165,
20 ]
21
22 # 6 GHz channels (WiFi 6E / 7, UNII-5 through UNII-8)
23 CHANNELS_6G = [
24 1, 5, 9, 13, 17, 21, 25, 29, # UNII-5
25 33, 37, 41, 45, 49, 53, 57, 61,
26 65, 69, 73, 77, 81, 85, 89, 93, # UNII-6
27 97, 101, 105, 109, 113, 117,
28 121, 125, 129, 133, 137, 141, # UNII-7
29 145, 149, 153, 157, 161, 165,
30 169, 173, 177, 181, 185, 189, # UNII-8
31 193, 197, 201, 205, 209, 213,
32 217, 221, 225, 229, 233,
33 ]
34
35 ALL_CHANNELS = CHANNELS_2G + CHANNELS_5G
36
37 # Channel -> Center Frequency (MHz) mapping
38 CHANNEL_TO_FREQ: dict[int, int] = {}
39 # 2.4 GHz: channel 1 = 2412 MHz, each channel +5 MHz, channel 14 = 2484 MHz
40 for ch in range(1, 14):
41 CHANNEL_TO_FREQ[ch] = 2407 + ch * 5
42 CHANNEL_TO_FREQ[14] = 2484
43 # 5 GHz: freq = 5000 + channel * 5
44 for ch in CHANNELS_5G:
45 CHANNEL_TO_FREQ[ch] = 5000 + ch * 5
46 # 6 GHz: freq = 5950 + channel * 5 (channel 1 = 5955 MHz)
47 CHANNEL_TO_FREQ_6G: dict[int, int] = {}
48 for ch in CHANNELS_6G:
49 CHANNEL_TO_FREQ_6G[ch] = 5950 + ch * 5
50
51 # Reverse mapping (2.4G + 5G only; 6G channels overlap with 2.4G/5G numbers)
52 FREQ_TO_CHANNEL: dict[int, int] = {freq: ch for ch, freq in CHANNEL_TO_FREQ.items()}
53 # Add 6 GHz reverse mapping (no overlap since frequencies are unique)
54 for ch, freq in CHANNEL_TO_FREQ_6G.items():
55 FREQ_TO_CHANNEL[freq] = ch
56
57
58 def freq_to_channel(freq_mhz: int) -> int:
59 """Convert frequency in MHz to channel number."""
60 if freq_mhz in FREQ_TO_CHANNEL:
61 return FREQ_TO_CHANNEL[freq_mhz]
62 # Approximate: find closest frequency
63 if 2400 <= freq_mhz <= 2500:
64 return round((freq_mhz - 2407) / 5)
65 elif 5925 <= freq_mhz <= 7125:
66 return round((freq_mhz - 5950) / 5)
67 elif freq_mhz >= 5000:
68 return round((freq_mhz - 5000) / 5)
69 return 0
70
71
72 def channel_to_freq(channel: int) -> int:
73 """Convert channel number to frequency in MHz."""
74 return CHANNEL_TO_FREQ.get(channel, 0)
75
76
77 def is_5ghz(channel: int) -> bool:
78 """Check if a channel is in the 5 GHz band."""
79 return channel in CHANNELS_5G
80
81
82 def channel_band(channel: int, freq: int = 0) -> str:
83 """Return '2.4', '5', or '6' for a given channel.
84
85 Uses frequency to disambiguate overlapping channel numbers between
86 2.4GHz/5GHz and 6GHz bands.
87 """
88 if freq >= 5925:
89 return "6"
90 if freq >= 4900:
91 return "5"
92 if freq > 0:
93 return "2.4"
94 # Fallback: channel number only (no freq info, cannot detect 6GHz)
95 return "5" if is_5ghz(channel) else "2.4"
96
97
98 def check_root() -> bool:
99 """Check if running as root."""
100 return os.geteuid() == 0
101
102
103 def check_iw() -> bool:
104 """Check if 'iw' command is available."""
105 return shutil.which("iw") is not None
106
107
108 def check_ip() -> bool:
109 """Check if 'ip' command is available."""
110 return shutil.which("ip") is not None
111
112
113 def find_wireless_interfaces() -> list[str]:
114 """Find available wireless interfaces by checking /sys/class/net/*/wireless."""
115 interfaces = []
116 net_path = Path("/sys/class/net")
117 if not net_path.exists():
118 return interfaces
119 for iface_path in net_path.iterdir():
120 wireless_path = iface_path / "wireless"
121 if wireless_path.exists():
122 interfaces.append(iface_path.name)
123 return sorted(interfaces)
124
125
126 def get_interface_mode(interface: str) -> str | None:
127 """Get the current mode of a wireless interface (managed, monitor, etc.)."""
128 try:
129 result = subprocess.run(
130 ["iw", "dev", interface, "info"],
131 capture_output=True, text=True, timeout=5,
132 )
133 if result.returncode != 0:
134 return None
135 for line in result.stdout.splitlines():
136 line = line.strip()
137 if line.startswith("type "):
138 return line.split("type ", 1)[1].strip()
139 except (subprocess.TimeoutExpired, FileNotFoundError):
140 return None
141 return None
142
143
144 def get_interface_driver(interface: str) -> str:
145 """Get the driver name for a wireless interface."""
146 driver_link = Path(f"/sys/class/net/{interface}/device/driver")
147 try:
148 return driver_link.resolve().name
149 except (OSError, ValueError):
150 return "unknown"
151
152
153 def get_supported_channels(interface: str) -> list[int]:
154 """Query the adapter's supported channels via 'iw phy'.
155
156 Returns a sorted list of channels the hardware can actually use.
157 Falls back to ALL_CHANNELS if the query fails.
158 """
159 # Get phy name for this interface
160 phy_path = Path(f"/sys/class/net/{interface}/phy80211/name")
161 try:
162 phy_name = phy_path.read_text().strip()
163 except (OSError, ValueError):
164 return ALL_CHANNELS
165
166 try:
167 result = subprocess.run(
168 ["iw", "phy", phy_name, "channels"],
169 capture_output=True, text=True, timeout=5,
170 )
171 if result.returncode != 0:
172 return ALL_CHANNELS
173 except (subprocess.TimeoutExpired, FileNotFoundError):
174 return ALL_CHANNELS
175
176 channels = []
177 for line in result.stdout.splitlines():
178 line = line.strip()
179 # Lines look like: "* 2412 MHz [1] (20.0 dBm)"
180 # or disabled: "* 5260 MHz [52] (disabled)"
181 if not line.startswith("*"):
182 continue
183 if "disabled" in line.lower():
184 continue
185 # Note: "no IR" channels are valid for monitor mode (receive-only)
186 # Extract channel number from [N]
187 bracket_start = line.find("[")
188 bracket_end = line.find("]")
189 if bracket_start == -1 or bracket_end == -1:
190 continue
191 try:
192 ch = int(line[bracket_start + 1 : bracket_end])
193 channels.append(ch)
194 except ValueError:
195 continue
196
197 return sorted(set(channels)) if channels else ALL_CHANNELS
198
199
200 def parse_channels_arg(channels_str: str) -> list[int]:
201 """Parse channel argument string like '1,6,11' or '36-48' into a list of channels."""
202 channels = []
203 for part in channels_str.split(","):
204 part = part.strip()
205 if "-" in part:
206 start, end = part.split("-", 1)
207 start, end = int(start.strip()), int(end.strip())
208 channels.extend(ch for ch in ALL_CHANNELS if start <= ch <= end)
209 else:
210 ch = int(part)
211 if ch in ALL_CHANNELS:
212 channels.append(ch)
213 return sorted(set(channels)) if channels else ALL_CHANNELS