blob: 75768dca9e0018678505771d09c126b1b2b4e915 [file] [log] [blame]
# Copyright (c) 2020 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from collections import namedtuple
import os
import re
import time
class WpaMon(object):
"""wpa_supplicant event monitor."""
WPAS_CTRL_DIR = '/var/run/wpa_supplicant/'
LOCAL_CTRL = 'local_ctrl'
REQUEST_PIPE = 'request_pipe'
WPAS_EVENT_LOG = 'wpa_event.log'
CTRL_EVENT_DO_ROAM = 'CTRL-EVENT-DO-ROAM'
CTRL_EVENT_SKIP_ROAM = 'CTRL-EVENT-SKIP-ROAM'
CTRL_EVENT_DISCONNECTED = 'CTRL-EVENT-DISCONNECTED'
CTRL_EVENT_SCAN_RESULTS = 'CTRL-EVENT-SCAN-RESULTS'
CTRL_EVENT_BSS_ADDED = 'CTRL-EVENT-BSS-ADDED'
ROAM_MATCH = ' cur_bssid=([\da-fA-F:]+) cur_freq=(\d+) ' \
'cur_level=([\d-]+) cur_est=(\d+) ' \
'sel_bssid=([\da-fA-F:]+) sel_freq=(\d+) ' \
'sel_level=([\d-]+) sel_est=(\d+)'
DISCONNECT_MATCH = ' bssid=([\da-fA-F:]+) reason=(\d+)' \
'(?: locally_generated=(1))?'
SCAN_RESULTS_MATCH = '()'
BSS_ADDED_MATCH = ' ([\d]+) ([\da-fA-F:]+)'
Roam = namedtuple('Roam',
['cur_bssid', 'cur_freq', 'cur_level', 'cur_est',
'sel_bssid', 'sel_freq', 'sel_level', 'sel_est'])
Disconnect = namedtuple('Disconnect', ['bssid', 'reason',
'locally_generated'])
ScanResults = namedtuple('ScanResults', [])
Bss = namedtuple('Bss', ['id', 'bssid'])
MatchFields = namedtuple('MatchFields', ['match_str', 'obj'])
EVENT_MATCH_DICT = \
{CTRL_EVENT_DO_ROAM: MatchFields(ROAM_MATCH, Roam),
CTRL_EVENT_SKIP_ROAM: MatchFields(ROAM_MATCH, Roam),
CTRL_EVENT_DISCONNECTED: MatchFields(DISCONNECT_MATCH, Disconnect),
CTRL_EVENT_SCAN_RESULTS: MatchFields(SCAN_RESULTS_MATCH, ScanResults),
CTRL_EVENT_BSS_ADDED: MatchFields(BSS_ADDED_MATCH, Bss),
}
def __init__(self, host, wifi_if):
self._host = host
self._dest = os.path.join(self.WPAS_CTRL_DIR, wifi_if)
self._pgid = None
self._started = False
def __enter__(self):
"""Connect to wpa_supplicant control interface."""
tmp_dir = self._host.get_tmp_dir()
tmp_dir = self._host.get_tmp_dir(parent=tmp_dir)
# Relax permissions for self._tmp_dir so that socat (run as wpa user)
# can create files in this directory.
self._host.run('chmod 777 %s' % tmp_dir)
local = os.path.join(tmp_dir, self.LOCAL_CTRL)
self._pipe = os.path.join(tmp_dir, self.REQUEST_PIPE)
self._log_path = os.path.join(tmp_dir, self.WPAS_EVENT_LOG)
# Run socat as wpa user so that the socket we bind to can be written to
# by wpa_supplicant. We use a `tail -f` on a named pipe to send requests
# to wpa_supplicant because `tail -f` continues to read even after it
# encounters an EOF. Using `cat` or the PIPE address type would close
# the input stream after the first write, instructing socat to tear
# everything else down.
command = "nohup sudo -u wpa -g wpa socat SYSTEM:'mkfifo %s; " \
"tail -f %s'\!\!STDOUT UNIX-CONNECT:%s,type=2,bind=%s " \
"</dev/null >%s 2>&1 & echo $!" % \
(self._pipe, self._pipe, self._dest, local, self._log_path)
out_lines = self._host.run(command).stdout.splitlines()
pid = int(out_lines[0])
self._pgid = \
int(self._host.run('ps -p %d -o pgid=' % pid).stdout.strip())
self._capture_index = 0
self._start()
return self
def __exit__(self, exception, value, traceback):
"""Disconnect from wpa_supplicant control interface."""
self._stop()
# socat spawns a subprocess with the SYSTEM address type, so we must
# kill the process group in order to properly clean up.
self._host.run('kill -- -%d' % self._pgid)
self._pgid = None
self._capture_index = 0
def _start(self):
"""
Attach to the wpa_supplicant control interface to start subscribing to
events.
@return False if already attached, True otherwise.
"""
if self._started:
return False
self._request('ATTACH')
self._started = True
return True
def _stop(self):
"""
Detach from the wpa_supplicant control interface to no longer receive
events.
@return False if not currently attached, True otherwise.
"""
if not self._started:
return False
self._request('DETACH')
self._started = False
return True
def _request(self, cmd):
"""
Send a request to the control interface by writing to the named pipe.
We use the -n option because wpa_supplicant expects there to be no
newline character after the command.
@param cmd string: command to run
"""
self._host.run('echo -n "%s" > %s' % (cmd, self._pipe))
def get_log_entries(self):
"""
Get all event log entries and command replies.
@return string event log
"""
return self._host.run('cat %s' % self._log_path).stdout.rstrip()
def start_event_capture(self):
"""
Set _capture_index to mark the point in the logs at which an event
capture was started.
"""
self._capture_index = len(self.get_log_entries())
def wait_for_event(self, event, timeout=10, sleep_interval=1.0, attrs={}):
"""
Wait for a wpa_supplicant event. start_event_capture should be called
before this.
@param event string: the wpa_supplicant event to wait for.
@param timeout int: timeout in seconds.
@param sleep_interval float: sleep interval in seconds.
@return list of strings of all event occurrences.
"""
start_time = time.time()
while True:
objs = self.get_events(event, True, attrs)
if objs:
return objs
if time.time() + sleep_interval - start_time > timeout:
return []
time.sleep(sleep_interval)
return []
def get_events(self, event, captured_events=False, attrs={}):
"""
Get all wpa_supplicant events of type |event|.
@param event string: the wpa_supplicant event to get.
@param captured_events boolean: True to get events starting from the
last start_event_capture call, False to get all events.
@return list of namedtuples corresponding to the event.
"""
wpa_log = self.get_log_entries()
if captured_events:
wpa_log = wpa_log[self._capture_index:]
match_str = event + self.EVENT_MATCH_DICT[event].match_str
matches = re.findall(match_str, wpa_log)
objs = []
for match in matches:
obj = self.EVENT_MATCH_DICT[event].obj(*match)
does_match = True
for attr, val in attrs.items():
if getattr(obj, attr) != val:
does_match = False
break
if does_match:
objs.append(obj)
return objs