1# Copyright (c) 2020 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from collections import namedtuple 6import os 7import re 8import time 9 10class WpaMon(object): 11 """wpa_supplicant event monitor.""" 12 13 WPAS_CTRL_DIR = '/var/run/wpa_supplicant/' 14 LOCAL_CTRL = 'local_ctrl' 15 REQUEST_PIPE = 'request_pipe' 16 WPAS_EVENT_LOG = 'wpa_event.log' 17 18 CTRL_EVENT_DO_ROAM = 'CTRL-EVENT-DO-ROAM' 19 CTRL_EVENT_SKIP_ROAM = 'CTRL-EVENT-SKIP-ROAM' 20 CTRL_EVENT_DISCONNECTED = 'CTRL-EVENT-DISCONNECTED' 21 CTRL_EVENT_SCAN_RESULTS = 'CTRL-EVENT-SCAN-RESULTS' 22 CTRL_EVENT_BSS_ADDED = 'CTRL-EVENT-BSS-ADDED' 23 24 ROAM_MATCH = ' cur_bssid=([\da-fA-F:]+) cur_freq=(\d+) ' \ 25 'cur_level=([\d-]+) cur_est=(\d+) ' \ 26 'sel_bssid=([\da-fA-F:]+) sel_freq=(\d+) ' \ 27 'sel_level=([\d-]+) sel_est=(\d+)' 28 DISCONNECT_MATCH = ' bssid=([\da-fA-F:]+) reason=(\d+)' \ 29 '(?: locally_generated=(1))?' 30 SCAN_RESULTS_MATCH = '()' 31 BSS_ADDED_MATCH = ' ([\d]+) ([\da-fA-F:]+)' 32 33 Roam = namedtuple('Roam', 34 ['cur_bssid', 'cur_freq', 'cur_level', 'cur_est', 35 'sel_bssid', 'sel_freq', 'sel_level', 'sel_est']) 36 Disconnect = namedtuple('Disconnect', ['bssid', 'reason', 37 'locally_generated']) 38 ScanResults = namedtuple('ScanResults', []) 39 Bss = namedtuple('Bss', ['id', 'bssid']) 40 41 MatchFields = namedtuple('MatchFields', ['match_str', 'obj']) 42 43 EVENT_MATCH_DICT = \ 44 {CTRL_EVENT_DO_ROAM: MatchFields(ROAM_MATCH, Roam), 45 CTRL_EVENT_SKIP_ROAM: MatchFields(ROAM_MATCH, Roam), 46 CTRL_EVENT_DISCONNECTED: MatchFields(DISCONNECT_MATCH, Disconnect), 47 CTRL_EVENT_SCAN_RESULTS: MatchFields(SCAN_RESULTS_MATCH, ScanResults), 48 CTRL_EVENT_BSS_ADDED: MatchFields(BSS_ADDED_MATCH, Bss), 49 } 50 51 def __init__(self, host, wifi_if): 52 self._host = host 53 self._dest = os.path.join(self.WPAS_CTRL_DIR, wifi_if) 54 self._pgid = None 55 self._started = False 56 57 def __enter__(self): 58 """Connect to wpa_supplicant control interface.""" 59 tmp_dir = self._host.get_tmp_dir() 60 tmp_dir = self._host.get_tmp_dir(parent=tmp_dir) 61 # Relax permissions for self._tmp_dir so that socat (run as wpa user) 62 # can create files in this directory. 63 self._host.run('chmod 777 %s' % tmp_dir) 64 local = os.path.join(tmp_dir, self.LOCAL_CTRL) 65 self._pipe = os.path.join(tmp_dir, self.REQUEST_PIPE) 66 self._log_path = os.path.join(tmp_dir, self.WPAS_EVENT_LOG) 67 # Run socat as wpa user so that the socket we bind to can be written to 68 # by wpa_supplicant. We use a `tail -f` on a named pipe to send requests 69 # to wpa_supplicant because `tail -f` continues to read even after it 70 # encounters an EOF. Using `cat` or the PIPE address type would close 71 # the input stream after the first write, instructing socat to tear 72 # everything else down. 73 command = "nohup sudo -u wpa -g wpa socat SYSTEM:'mkfifo %s; " \ 74 "tail -f %s'\!\!STDOUT UNIX-CONNECT:%s,type=2,bind=%s " \ 75 "</dev/null >%s 2>&1 & echo $!" % \ 76 (self._pipe, self._pipe, self._dest, local, self._log_path) 77 out_lines = self._host.run(command).stdout.splitlines() 78 pid = int(out_lines[0]) 79 self._pgid = \ 80 int(self._host.run('ps -p %d -o pgid=' % pid).stdout.strip()) 81 self._capture_index = 0 82 self._start() 83 return self 84 85 def __exit__(self, exception, value, traceback): 86 """Disconnect from wpa_supplicant control interface.""" 87 self._stop() 88 # socat spawns a subprocess with the SYSTEM address type, so we must 89 # kill the process group in order to properly clean up. 90 self._host.run('kill -- -%d' % self._pgid) 91 self._pgid = None 92 self._capture_index = 0 93 94 def _start(self): 95 """ 96 Attach to the wpa_supplicant control interface to start subscribing to 97 events. 98 99 @return False if already attached, True otherwise. 100 """ 101 if self._started: 102 return False 103 self._request('ATTACH') 104 self._started = True 105 return True 106 107 def _stop(self): 108 """ 109 Detach from the wpa_supplicant control interface to no longer receive 110 events. 111 112 @return False if not currently attached, True otherwise. 113 """ 114 if not self._started: 115 return False 116 self._request('DETACH') 117 self._started = False 118 return True 119 120 def _request(self, cmd): 121 """ 122 Send a request to the control interface by writing to the named pipe. 123 124 We use the -n option because wpa_supplicant expects there to be no 125 newline character after the command. 126 127 @param cmd string: command to run 128 """ 129 self._host.run('echo -n "%s" > %s' % (cmd, self._pipe)) 130 131 def get_log_entries(self): 132 """ 133 Get all event log entries and command replies. 134 135 @return string event log 136 """ 137 return self._host.run('cat %s' % self._log_path).stdout.rstrip() 138 139 def start_event_capture(self): 140 """ 141 Set _capture_index to mark the point in the logs at which an event 142 capture was started. 143 """ 144 self._capture_index = len(self.get_log_entries()) 145 146 def wait_for_event(self, event, timeout=10, sleep_interval=1.0, attrs={}): 147 """ 148 Wait for a wpa_supplicant event. start_event_capture should be called 149 before this. 150 151 @param event string: the wpa_supplicant event to wait for. 152 @param timeout int: timeout in seconds. 153 @param sleep_interval float: sleep interval in seconds. 154 @return list of strings of all event occurrences. 155 """ 156 start_time = time.time() 157 while True: 158 objs = self.get_events(event, True, attrs) 159 if objs: 160 return objs 161 if time.time() + sleep_interval - start_time > timeout: 162 return [] 163 time.sleep(sleep_interval) 164 return [] 165 166 def get_events(self, event, captured_events=False, attrs={}): 167 """ 168 Get all wpa_supplicant events of type |event|. 169 170 @param event string: the wpa_supplicant event to get. 171 @param captured_events boolean: True to get events starting from the 172 last start_event_capture call, False to get all events. 173 @return list of namedtuples corresponding to the event. 174 """ 175 wpa_log = self.get_log_entries() 176 if captured_events: 177 wpa_log = wpa_log[self._capture_index:] 178 match_str = event + self.EVENT_MATCH_DICT[event].match_str 179 matches = re.findall(match_str, wpa_log) 180 objs = [] 181 for match in matches: 182 obj = self.EVENT_MATCH_DICT[event].obj(*match) 183 does_match = True 184 for attr, val in attrs.items(): 185 if getattr(obj, attr) != val: 186 does_match = False 187 break 188 if does_match: 189 objs.append(obj) 190 return objs 191