ap_scanner/display.py (13579B)
1 """Terminal display: real-time AP listing with ANSI escape sequences."""
2
3 import os
4 import sys
5 import unicodedata
6 from datetime import datetime, timedelta
7
8 from ap_scanner import __version__
9 from ap_scanner.models import AccessPoint
10
11
12 def _display_width(s: str) -> int:
13 """Calculate terminal display width accounting for wide (CJK) characters."""
14 width = 0
15 for ch in s:
16 eaw = unicodedata.east_asian_width(ch)
17 width += 2 if eaw in ("W", "F") else 1
18 return width
19
20
21 def _truncate_to_width(s: str, max_width: int) -> str:
22 """Truncate string to fit within max_width terminal cells, adding ellipsis if needed."""
23 width = 0
24 for i, ch in enumerate(s):
25 eaw = unicodedata.east_asian_width(ch)
26 char_width = 2 if eaw in ("W", "F") else 1
27 if width + char_width > max_width:
28 return s[:i] + "\u2026"
29 width += char_width
30 return s
31
32
33 def _pad_to_width(s: str, target_width: int) -> str:
34 """Pad string with spaces to reach target terminal width."""
35 current = _display_width(s)
36 return s + " " * max(0, target_width - current)
37
38
39 # ANSI color codes
40 class Colors:
41 RESET = "\033[0m"
42 BOLD = "\033[1m"
43 DIM = "\033[2m"
44 RED = "\033[31m"
45 GREEN = "\033[32m"
46 YELLOW = "\033[33m"
47 BLUE = "\033[34m"
48 MAGENTA = "\033[35m"
49 CYAN = "\033[36m"
50 WHITE = "\033[37m"
51 BG_BLUE = "\033[44m"
52 REVERSE = "\033[7m"
53
54
55 class NoColors:
56 """No-op colors for --no-color mode."""
57 RESET = BOLD = DIM = ""
58 RED = GREEN = YELLOW = BLUE = MAGENTA = CYAN = WHITE = ""
59 BG_BLUE = REVERSE = ""
60
61
62 class Display:
63 """Renders AP scan results to the terminal in airodump-ng style."""
64
65 SORT_KEYS = {
66 "rssi": lambda ap: (ap.rssi if ap.rssi is not None else -999, ap.bssid),
67 "channel": lambda ap: (ap.channel, ap.bssid),
68 "essid": lambda ap: (ap.display_ssid.lower(), ap.bssid),
69 "enc": lambda ap: (ap.encryption, ap.bssid),
70 }
71
72 SORT_REVERSE = {
73 "rssi": True, # strongest first (-42 > -80)
74 "channel": False,
75 "essid": False,
76 "enc": False,
77 }
78
79 NEW_AP_HIGHLIGHT_SECS = 5
80
81 def __init__(self, no_color: bool = False):
82 self.c = NoColors if no_color else Colors
83 self.sort_field = "rssi"
84 self.band_filter: str | None = None # None = all, "2.4GHz", "5GHz"
85 self._start_time = datetime.now()
86 self._first_render = True
87 self._known_bssids: set[str] = set()
88 self._new_bssids: dict[str, datetime] = {} # bssid -> discovered time
89 self._selected_index: int = 0
90 self._selection_mode: bool = False
91 self._status_message: str = ""
92
93 def set_sort(self, field: str) -> None:
94 if field in self.SORT_KEYS:
95 self.sort_field = field
96
97 def cycle_sort(self) -> str:
98 """Cycle through sort fields. Returns the new sort field name."""
99 fields = list(self.SORT_KEYS.keys())
100 idx = fields.index(self.sort_field)
101 self.sort_field = fields[(idx + 1) % len(fields)]
102 return self.sort_field
103
104 def cycle_band(self) -> str:
105 """Cycle through band filters: all -> 2.4GHz -> 5GHz -> 6GHz -> all."""
106 cycle = [None, "2.4GHz", "5GHz", "6GHz"]
107 try:
108 idx = cycle.index(self.band_filter)
109 except ValueError:
110 idx = 0
111 self.band_filter = cycle[(idx + 1) % len(cycle)]
112 return self.band_filter or "all"
113
114 def enter_selection_mode(self) -> None:
115 """Enter AP selection mode for interactive navigation."""
116 self._selection_mode = True
117 self._selected_index = 0
118
119 def exit_selection_mode(self) -> None:
120 """Exit AP selection mode."""
121 self._selection_mode = False
122 self._selected_index = 0
123
124 def move_selection(self, delta: int, total: int) -> None:
125 """Move selection cursor by delta, clamping to valid range."""
126 if total <= 0:
127 self._selected_index = 0
128 return
129 self._selected_index = max(0, min(total - 1, self._selected_index + delta))
130
131 def get_sorted_filtered(self, aps: dict[str, "AccessPoint"]) -> list["AccessPoint"]:
132 """Return the currently filtered and sorted AP list (matches render order)."""
133 filtered = aps.values()
134 if self.band_filter:
135 filtered = [ap for ap in filtered if ap.band == self.band_filter]
136 else:
137 filtered = list(filtered)
138 sort_key = self.SORT_KEYS[self.sort_field]
139 reverse = self.SORT_REVERSE[self.sort_field]
140 return sorted(filtered, key=sort_key, reverse=reverse)
141
142 def set_status(self, msg: str) -> None:
143 """Set a persistent status message (cleared on next render if empty)."""
144 self._status_message = msg
145
146 def render(
147 self,
148 aps: dict[str, AccessPoint],
149 interface: str,
150 current_channel: int,
151 ) -> None:
152 """Clear screen and render the AP table."""
153 c = self.c
154 term_width, term_height = os.get_terminal_size()
155
156 # Reuse get_sorted_filtered to avoid duplicating filter+sort logic
157 sorted_aps = self.get_sorted_filtered(aps)
158
159 # Elapsed time
160 elapsed = datetime.now() - self._start_time
161 elapsed_str = str(timedelta(seconds=int(elapsed.total_seconds())))
162
163 # Count stats in single pass
164 total = len(aps)
165 count_2g = count_5g = count_6g = count_hidden = 0
166 for ap in aps.values():
167 band = ap.band
168 if band == "2.4GHz":
169 count_2g += 1
170 elif band == "5GHz":
171 count_5g += 1
172 elif band == "6GHz":
173 count_6g += 1
174 if ap.is_hidden:
175 count_hidden += 1
176
177 # Build output
178 lines: list[str] = []
179
180 # First render: clear entire screen; subsequent: just move cursor home
181 if self._first_render:
182 lines.append("\033[2J\033[H")
183 self._first_render = False
184 else:
185 lines.append("\033[H")
186
187 # Header
188 band_str = f"Band: {self.band_filter or 'all'}"
189 sort_str = f"Sort: {self.sort_field}"
190 header_text = (
191 f" UAF v{__version__} | {interface} | Monitor | "
192 f"CH: {current_channel:<3} | {elapsed_str} | "
193 f"{band_str} | {sort_str}"
194 )
195 header = f" {c.BOLD}{c.WHITE}{c.BG_BLUE}{header_text}"
196 padding = max(0, term_width - len(header_text) - 1)
197 header += " " * padding + c.RESET
198 lines.append(header)
199 lines.append("")
200
201 # Column headers
202 col_header = (
203 f" {c.BOLD}{c.CYAN}"
204 f"{'SSID':<24s} {'BSSID':<18s} {'RSSI':>5s} "
205 f"{'CH':<16s} {'Band':<6s} {'W':>3s} "
206 f"{'802.11':<14s} "
207 f"{'ENC':<10s} {'WPA2':<12s} {'WPA3':<12s} "
208 f"{'WPS':<4s} {'Vendor'}"
209 f"{c.RESET}"
210 )
211 lines.append(col_header)
212
213 # Separator
214 lines.append(f" {c.DIM}{'-' * min(term_width - 2, 150)}{c.RESET}")
215
216 # AP rows
217 max_rows = max(term_height - 9, 5)
218 now = datetime.now()
219
220 # Track newly discovered APs
221 for bssid in aps:
222 if bssid not in self._known_bssids:
223 self._known_bssids.add(bssid)
224 self._new_bssids[bssid] = now
225 # Expire old highlights
226 expired = [b for b, t in self._new_bssids.items()
227 if (now - t).total_seconds() > self.NEW_AP_HIGHLIGHT_SECS]
228 for b in expired:
229 del self._new_bssids[b]
230
231 for i, ap in enumerate(sorted_aps[:max_rows]):
232 rssi_str = f"{ap.rssi:>4d}" if ap.rssi is not None else " N/A"
233 rssi_color = self._rssi_color(ap.rssi)
234
235 is_open = ap.encryption == "OPN"
236 # Highlight newly discovered APs
237 is_new = ap.bssid in self._new_bssids
238 # Dim APs not seen in last 30 seconds
239 stale = (now - ap.last_seen) > timedelta(seconds=30)
240
241 # Selection mode: selected row gets REVERSE video
242 if self._selection_mode and i == self._selected_index:
243 row_prefix = c.REVERSE
244 elif is_new:
245 row_prefix = f"{c.BOLD}{c.GREEN}"
246 elif stale:
247 row_prefix = c.DIM
248 elif is_open:
249 row_prefix = f"{c.BOLD}{c.RED}"
250 else:
251 row_prefix = ""
252
253 ssid = _truncate_to_width(ap.display_ssid, 23)
254 ssid_padded = _pad_to_width(ssid, 24)
255 if ap.is_hidden and not is_new:
256 ssid_str = f"{c.RED}{ssid_padded}{c.RESET}{row_prefix}"
257 else:
258 ssid_str = ssid_padded
259
260 enc_color = self._enc_color(ap.encryption)
261 vendor = ap.vendor[:20] if ap.vendor else ""
262
263 line = (
264 f" {row_prefix}"
265 f"{ssid_str}"
266 f"{ap.bssid:<18s} "
267 f"{rssi_color}{rssi_str}{c.RESET}{row_prefix} "
268 f"{ap.channel_display:<16s} "
269 f"{ap.band:<6s} {ap.width:>3d} "
270 f"{ap.standard_display:<14s} "
271 f"{enc_color}{ap.encryption:<10s}{c.RESET}{row_prefix} "
272 f"{ap.wpa2:<12s} {ap.wpa3:<12s} "
273 f"{ap.wps:<4s} {vendor}"
274 f"{c.RESET}"
275 )
276 lines.append(line)
277
278 # Remaining count
279 if len(sorted_aps) > max_rows:
280 remaining = len(sorted_aps) - max_rows
281 lines.append(f" {c.DIM}... +{remaining} more APs{c.RESET}")
282
283 # Padding to push footer to bottom (status + stats + keys = 4 lines)
284 current_lines = len(lines)
285 for _ in range(max(0, term_height - current_lines - 4)):
286 lines.append("")
287
288 # Status message (e.g., active scan progress)
289 if self._status_message:
290 lines.append(f" {c.BOLD}{c.YELLOW}{self._status_message}{c.RESET}")
291 else:
292 lines.append("")
293
294 # Stats line
295 stats = (
296 f" {c.BOLD}"
297 f"APs: {c.WHITE}{total}{c.RESET}{c.BOLD} | "
298 f"2.4GHz: {c.GREEN}{count_2g}{c.RESET}{c.BOLD} | "
299 f"5GHz: {c.CYAN}{count_5g}{c.RESET}{c.BOLD} | "
300 f"6GHz: {c.MAGENTA}{count_6g}{c.RESET}{c.BOLD} | "
301 f"Hidden: {c.RED}{count_hidden}{c.RESET}"
302 )
303 lines.append(stats)
304
305 # Keybindings (mode-dependent)
306 if self._selection_mode:
307 keys = (
308 f" {c.DIM}"
309 f"[\u2191/\u2193] Navigate | [Enter] Active Scan | [Esc] Cancel"
310 f"{c.RESET}"
311 )
312 else:
313 keys = (
314 f" {c.DIM}"
315 f"[q] Quit | [w] CSV | [j] JSON | "
316 f"[b] Band | [s] Sort | [Enter] Select AP"
317 f"{c.RESET}"
318 )
319 lines.append(keys)
320
321 # Append clear-to-end-of-line on every line so leftover text is erased,
322 # then clear any remaining lines below the last one we wrote.
323 eol = "\033[K"
324 output = ("\n" + eol).join(lines)
325 # Erase from current position to bottom of screen
326 output += "\033[J"
327 sys.stdout.write(output)
328 sys.stdout.flush()
329
330 def _rssi_color(self, rssi: int | None) -> str:
331 c = self.c
332 if rssi is None:
333 return c.DIM
334 if rssi >= -50:
335 return c.GREEN
336 if rssi >= -70:
337 return c.YELLOW
338 return c.RED
339
340 def _enc_color(self, encryption: str) -> str:
341 c = self.c
342 if encryption == "OPN":
343 return c.RED
344 if encryption in ("WPA3", "WPA2/WPA3"):
345 return c.GREEN
346 if encryption == "WPA2":
347 return c.CYAN
348 if encryption == "WPA":
349 return c.YELLOW
350 if encryption == "WEP":
351 return c.RED
352 return ""
353
354 def render_overlay(self, lines_content: list[str], title: str = "") -> None:
355 """Render a centered overlay panel on top of the AP table."""
356 c = self.c
357 term_width, term_height = os.get_terminal_size()
358 box_width = min(term_width - 4, 72)
359 start_row = max(3, (term_height - len(lines_content) - 4) // 2)
360
361 border_h = "+" + "-" * (box_width - 2) + "+"
362
363 sys.stdout.write(f"\033[{start_row};1H")
364 sys.stdout.write(f" {c.BOLD}{c.CYAN}{border_h}{c.RESET}\033[K\n")
365
366 if title:
367 title_line = f"| {title}"
368 pad = box_width - 3 - _display_width(title)
369 title_line += " " * max(0, pad) + "|"
370 sys.stdout.write(f" {c.BOLD}{c.CYAN}{title_line}{c.RESET}\033[K\n")
371 sys.stdout.write(f" {c.BOLD}{c.CYAN}|{' ' * (box_width - 2)}|{c.RESET}\033[K\n")
372
373 for line in lines_content:
374 display_line = _truncate_to_width(line, box_width - 4)
375 pad = box_width - 2 - _display_width(display_line) - 1
376 row = f"| {display_line}" + " " * max(0, pad) + "|"
377 sys.stdout.write(f" {c.CYAN}{row}{c.RESET}\033[K\n")
378
379 sys.stdout.write(f" {c.BOLD}{c.CYAN}{border_h}{c.RESET}\033[K\n")
380 sys.stdout.write(
381 f" {c.DIM}Press any key to dismiss{c.RESET}\033[K"
382 )
383 sys.stdout.flush()
384
385 def show_message(self, msg: str) -> None:
386 """Show a temporary message at the bottom of the screen."""
387 c = self.c
388 _, term_height = os.get_terminal_size()
389 sys.stdout.write(f"\033[{term_height};0H\033[2K {c.BOLD}{c.GREEN}{msg}{c.RESET}")
390 sys.stdout.flush()