blob: a25594f39a240ef9d24976f2f99b0b01931423a3 [file] [log] [blame]
# Copyright (c) 2013 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.
import logging
import multiprocessing
import re
import select
import time
from autotest_lib.client.common_lib import error
from autotest_lib.client.common_lib.cros.network import ping_runner
from autotest_lib.client.common_lib.cros.network import xmlrpc_datatypes
from autotest_lib.server import site_attenuator
from autotest_lib.server.cros.network import hostap_config
from autotest_lib.server.cros.network import rvr_test_base
class Reporter(object):
"""Object that forwards stdout from Host.run to a pipe.
The |stdout_tee| parameter for Host.run() requires an object that looks
like a Python built-in file. In particular, it needs 'flush', which a
multiprocessing.Connection (the object returned by multiprocessing.Pipe)
doesn't have. This wrapper provides that functionaly in order to allow a
pipe to be the target of a stdout_tee.
"""
def __init__(self, write_pipe):
"""Initializes reporter.
@param write_pipe: the place to send output.
"""
self._write_pipe = write_pipe
def flush(self):
"""Flushes the output - not used by the pipe."""
pass
def close(self):
"""Closes the pipe."""
return self._write_pipe.close()
def fileno(self):
"""Returns the file number of the pipe."""
return self._write_pipe.fileno()
def write(self, string):
"""Write to the pipe.
@param string: the string to write to the pipe.
"""
self._write_pipe.send(string)
def writelines(self, sequence):
"""Write a number of lines to the pipe.
@param sequence: the array of lines to be written.
"""
for string in sequence:
self._write_pipe.send(string)
class LaunchIwEvent(object):
"""Calls 'iw event' and searches for a list of events in its output.
This class provides a framework for launching 'iw event' in its own
process and searching its output for an ordered list of events expressed
as regular expressions.
Expected to be called as follows:
launch_iw_event = LaunchIwEvent('iw',
self.context.client.host,
timeout_seconds=60.0)
# Do things that cause nl80211 traffic
# Now, wait for the results you want.
if not launch_iw_event.wait_for_events(['RSSI went below threshold',
'scan started',
# ...
'connected to']):
raise error.TestFail('Did not find all expected events')
"""
# A timeout from Host.run(timeout) kills the process and that takes a
# few seconds. Therefore, we need to add some margin to the select
# timeout (which will kill the process if Host.run(timeout) fails for some
# reason).
TIMEOUT_MARGIN_SECONDS = 5
def __init__(self, iw_command, dut, timeout_seconds):
"""Launches 'iw event' process with communication channel for output
@param dut: Host object for the dut
@param timeout_seconds: timeout for 'iw event' (since it never
returns)
"""
self._iw_command = iw_command
self._dut = dut
self._timeout_seconds = timeout_seconds
self._pipe_reader, pipe_writer = multiprocessing.Pipe()
self._iw_event = multiprocessing.Process(target=self.do_iw,
args=(pipe_writer,
self._timeout_seconds,))
self._iw_event.start()
def do_iw(self, connection, timeout_seconds):
"""Runs 'iw event'
iw results are passed back, on the fly, through a supplied connection
object. The process terminates itself after a specified timeout.
@param connection: a Connection object to which results are written.
@param timeout_seconds: number of seconds before 'iw event' is killed.
"""
reporter = Reporter(connection)
# ignore_timeout just ignores the _exception_; the timeout is still
# valid.
self._dut.run('%s event' % self._iw_command,
timeout=timeout_seconds,
stdout_tee=reporter,
ignore_timeout=True)
def wait_for_events(self, expected_events):
"""Waits for 'expected_events' (in order) from iw.
@param expected_events: a list of strings that are regular expressions.
This method searches for the each expression, in the order that they
appear in |expected_events|, in the stream of output from iw. x
@returns: True if all events were found. False, otherwise.
"""
if not expected_events:
logging.error('No events')
return False
expected_event = expected_events.pop(0)
done_time = (time.time() + self._timeout_seconds +
LaunchIwEvent.TIMEOUT_MARGIN_SECONDS)
received_event_log = []
while expected_event:
timeout = done_time - time.time()
if timeout <= 0:
break
(sread, swrite, sexec) = select.select(
[self._pipe_reader], [], [], timeout)
if sread:
received_event = sread[0].recv()
received_event_log.append(received_event)
if re.search(expected_event, received_event):
logging.info('Found expected event: "%s"',
received_event.rstrip())
if expected_events:
expected_event = expected_events.pop(0)
else:
expected_event = None
logging.info('Found ALL expected events')
break
else: # Timeout.
break
if expected_event:
logging.error('Never found expected event "%s". iw log:',
expected_event)
for event in received_event_log:
logging.error(event.rstrip())
return False
return True
class ModifiedRoamThreshold(object):
"""Context manager manages wpa_supplicant's roam_threshold"""
def __init__(self, client, roam_threshold):
"""Saves parameters for __enter__.
@param client: WiFiClient
@param roam_threshold: integer: supplicant's new value for roam
threshold.
"""
self._client = client
self._original_threshold = None
self._roam_threshold = roam_threshold
def __enter__(self):
"""Saves current roam threshold, sets a new one."""
logging.info('- Setting supplicant\'s roam threshold')
self._original_threshold = self._client.get_roam_threshold(
self._client.wifi_if)
if self._original_threshold is None:
raise error.TestFail('Device (%s) or property not found.' %
self._client.wifi_if)
if not self._client.set_roam_threshold(self._client.wifi_if,
self._roam_threshold):
raise error.TestFail('Could not set roam threshold')
logging.info('changed roam threshold from %r to %r',
self._original_threshold,
self._client.get_roam_threshold(self._client.wifi_if))
def __exit__(self, type, value, traceback):
"""Reinstates the previous roam threshold.
@param type: exception type - unused
@param value: exception value - unused
@param traceback: exception traceback - unused
"""
logging.info('- Resetting supplicant\'s roam threshold to %d',
self._original_threshold)
if not self._client.set_roam_threshold(self._client.wifi_if,
self._original_threshold):
raise error.TestFail('Could not reset roam threshold')
class network_WiFi_RoamOnLowPower(rvr_test_base.RvRTestBase):
"""Tests roaming to an AP when the old one's signal is too weak.
This test uses a dual-radio Stumpy as the AP and configures the radios to
broadcast two BSS's with different frequencies on the same SSID. The DUT
connects to the first radio, the test attenuates that radio, and the DUT
is supposed to roam to the second radio.
This test requires a particular configuration of test equipment:
+--------- StumpyCell/AP ----------+
| chromeX.grover.hostY.router.cros |
| |
| [Radio 0] [Radio 1] |
+--------A-----B----C-----D--------+
+------ BeagleBone ------+ | | | |
| chromeX.grover.hostY. | | X | X
| attenuator.cros [Port0]-[attenuator] |
| [Port1]----- | ----[attenuator]
| [Port2]-X | |
| [Port3]-X +-----+ |
| | | |
+------------------------+ | |
+--------------E----F--------------+
| [Radio 0] |
| |
| chromeX.grover.hostY.cros |
+-------------- DUT ---------------+
Where antennas A, C, and E are the primary antennas for AP/radio0,
AP/radio1, and DUT/radio0, respectively; and antennas B, D, and F are the
auxilliary antennas for AP/radio0, AP/radio1, and DUT/radio0,
respectively. The BeagleBone controls 2 attenuators that are connected
to the primary antennas of AP/radio0 and 1 which are fed into the primary
and auxilliary antenna ports of DUT/radio 0. Ports 2 and 3 of the
BeagleBone as well as the auxillary antennae of AP/radio0 and 1 are
terminated.
This arrangement ensures that the attenuator port numbers are assigned to
the primary radio, first, and the secondary radio, second. If this happens,
the ports will be numbered in the order in which the AP's channels are
configured (port 0 is first, port 1 is second, etc.).
This test is a de facto test that the ports are configured in that
arrangement since swapping Port0 and Port1 would cause us to attenuate the
secondary radio, providing no impetus for the DUT to switch radios and
causing the test to fail to connect at radio 1's frequency.
"""
version = 1
FREQUENCY_0 = 2412
FREQUENCY_1 = 2462
PORT_0 = 0 # Port created first (on FREQUENCY_0)
PORT_1 = 1 # Port created second (on FREQUENCY_1)
# Supplicant's signal to noise threshold for roaming. When noise is
# measurable and S/N is less than the threshold, supplicant will attempt
# to roam. We're setting the roam threshold (and setting it so high --
# it's usually 18) because some of the DUTs we're using have a hard time
# measuring signals below -55 dBm. A threshold of 40 roams when the
# signal is about -50 dBm (since the noise tends to be around -89).
ABSOLUTE_ROAM_THRESHOLD_DB = 40
def run_once(self):
"""Test body."""
self.context.client.clear_supplicant_blacklist()
with ModifiedRoamThreshold(self.context.client,
self.ABSOLUTE_ROAM_THRESHOLD_DB):
logging.info('- Configure first AP & connect')
self.context.configure(hostap_config.HostapConfig(
frequency=self.FREQUENCY_0,
mode=hostap_config.HostapConfig.MODE_11G))
router_ssid = self.context.router.get_ssid()
self.context.assert_connect_wifi(xmlrpc_datatypes.
AssociationParameters(
ssid=router_ssid))
self.context.assert_ping_from_dut()
# Setup background scan configuration to set a signal level, below
# which, supplicant will scan (3dB below the current level). We
# must reconnect for these parameters to take effect.
logging.info('- Set background scan level')
bgscan_config = xmlrpc_datatypes.BgscanConfiguration(
method='simple',
signal=self.context.client.wifi_signal_level - 3)
self.context.client.shill.disconnect(router_ssid)
self.context.assert_connect_wifi(
xmlrpc_datatypes.AssociationParameters(
ssid=router_ssid, bgscan_config=bgscan_config))
logging.info('- Configure second AP')
self.context.configure(hostap_config.HostapConfig(
ssid=router_ssid,
frequency=self.FREQUENCY_1,
mode=hostap_config.HostapConfig.MODE_11G),
multi_interface=True)
launch_iw_event = LaunchIwEvent('iw',
self.context.client.host,
timeout_seconds=60.0)
logging.info('- Drop the power on the first AP')
self.set_signal_to_force_roam(port=self.PORT_0,
frequency=self.FREQUENCY_0)
# Verify that the low signal event is generated, that supplicant
# scans as a result (or, at least, that supplicant scans after the
# threshold is passed), and that it connects to something.
logging.info('- Wait for RSSI threshold drop, scan, and connect')
if not launch_iw_event.wait_for_events(['RSSI went below threshold',
'scan started',
'connected to']):
raise error.TestFail('Did not find all expected events')
logging.info('- Wait for a connection on the second AP')
# Instead of explicitly connecting, just wait to see if the DUT
# connects to the second AP by itself
self.context.wait_for_connection(ssid=router_ssid,
freq=self.FREQUENCY_1, ap_num=1)
# Clean up.
self.context.router.deconfig()
def set_signal_to_force_roam(self, port, frequency):
"""Adjust the AP attenuation to force the DUT to roam.
wpa_supplicant (v2.0-devel) decides when to roam based on a number of
factors even when we're only interested in the scenario when the roam
is instigated by an RSSI drop. The gates for roaming differ between
systems that have drivers that measure noise and those that don't. If
the driver reports noise, the S/N of both the current BSS and the
target BSS is capped at 30 and then the following conditions must be
met:
1) The S/N of the current AP must be below supplicant's roam
threshold.
2) The S/N of the roam target must be more than 3dB larger than
that of the current BSS.
If the driver does not report noise, the following condition must be
met:
3) The roam target's signal must be above the current BSS's signal
by a signal-dependent value (that value doesn't currently go
higher than 5).
This would all be enough complication. Unfortunately, the DUT's signal
measurement hardware has typically not been optimized for accurate
measurement throughout the signal range. Based on some testing
(crbug:295752), it was discovered that the DUT's measurements of signal
levels somewhere below -50dBm show values greater than the actual signal
and with quite a bit of variance. Since wpa_supplicant uses this same
mechanism to read its levels, this code must iterate to find values that
will reliably trigger supplicant to roam to the second AP.
It was also shown that some MIMO DUTs send different signal levels to
their two radios (testing has shown this to be somewhere around 5dB to
7dB).
@param port: the beaglebone port that is desired to be attenuated.
@param frequency: noise needs to be read for a frequency.
"""
# wpa_supplicant calls an S/N of 30 dB "quite good signal" and caps the
# S/N at this level for the purposes of roaming calculations. We'll do
# the same (since we're trying to instigate behavior in supplicant).
GREAT_SNR = 30
# The difference between the S/Ns of APs from 2), above.
MIN_AP_SIGNAL_DIFF_FOR_ROAM_DB = 3
# The maximum delta for a system that doesn't measure noise, from 3),
# above.
MIN_NOISELESS_SIGNAL_DIFF_FOR_ROAM_DB = 5
# Adds a clear margin to attenuator levels to make sure that we
# attenuate enough to do the job in light of signal and noise levels
# that bounce around. This value was reached empirically and further
# tweaking may be necessary if this test gets flaky.
SIGNAL_TO_NOISE_MARGIN_DB = 3
# The measured difference between the radios on one of our APs.
# TODO(wdg): dynamically measure the difference between the AP's radios
# (crbug:307678).
TEST_HW_SIGNAL_DELTA_DB = 7
# wpa_supplicant's roaming algorithm differs between systems that can
# measure noise and those that can't. This code tracks those
# differences.
actual_signal_dbm = self.context.client.wifi_signal_level
actual_noise_dbm = self.context.client.wifi_noise_level(frequency)
logging.info('Radio 0 signal: %r, noise: %r', actual_signal_dbm,
actual_noise_dbm)
if actual_noise_dbm is not None:
system_measures_noise = True
actual_snr_db = actual_signal_dbm - actual_noise_dbm
radio1_snr_db = actual_snr_db - TEST_HW_SIGNAL_DELTA_DB
# Supplicant will cap any S/N measurement used for roaming at
# GREAT_SNR so we'll do the same.
if radio1_snr_db > GREAT_SNR:
radio1_snr_db = GREAT_SNR
# In order to roam, the S/N of radio 0 must be both less than 3db
# below radio1 and less than the roam threshold.
logging.info('Radio 1 S/N = %d', radio1_snr_db)
delta_snr_threshold_db = (radio1_snr_db -
MIN_AP_SIGNAL_DIFF_FOR_ROAM_DB)
if (delta_snr_threshold_db < self.ABSOLUTE_ROAM_THRESHOLD_DB):
target_snr_db = delta_snr_threshold_db
logging.info('Target S/N = %d (delta algorithm)',
target_snr_db)
else:
target_snr_db = self.ABSOLUTE_ROAM_THRESHOLD_DB
logging.info('Target S/N = %d (threshold algorithm)',
target_snr_db)
# Add some margin.
target_snr_db -= SIGNAL_TO_NOISE_MARGIN_DB
attenuation_db = actual_snr_db - target_snr_db
logging.info('Noise: target S/N=%d attenuation=%r',
target_snr_db, attenuation_db)
else:
system_measures_noise = False
# On a system that doesn't measure noise, supplicant needs the
# signal from radio 0 to be less than that of radio 1 minus a fixed
# delta value. While we're here, subtract additional margin from
# the target value.
target_signal_dbm = (actual_signal_dbm - TEST_HW_SIGNAL_DELTA_DB -
MIN_NOISELESS_SIGNAL_DIFF_FOR_ROAM_DB -
SIGNAL_TO_NOISE_MARGIN_DB)
attenuation_db = actual_signal_dbm - target_signal_dbm
logging.info('No noise: target_signal=%r, attenuation=%r',
target_signal_dbm, attenuation_db)
# Attenuate, measure S/N, repeat (due to flaky measurments) until S/N is
# where we want it.
keep_tweaking_snr = True
while keep_tweaking_snr:
# Keep attenuation values below the attenuator's maximum.
if attenuation_db > (site_attenuator.Attenuator.
MAX_VARIABLE_ATTENUATION):
attenuation_db = (site_attenuator.Attenuator.
MAX_VARIABLE_ATTENUATION)
logging.info('Applying attenuation=%r', attenuation_db)
self.context.attenuator.set_variable_attenuation_on_port(
port, attenuation_db)
if attenuation_db >= (site_attenuator.Attenuator.
MAX_VARIABLE_ATTENUATION):
logging.warning('. NOTICE: Attenuation is at maximum value')
keep_tweaking_snr = False
elif system_measures_noise:
actual_snr_db = self.get_signal_to_noise(frequency)
if actual_snr_db > target_snr_db:
logging.info('. S/N (%d) > target value (%d)',
actual_snr_db, target_snr_db)
attenuation_db += actual_snr_db - target_snr_db
else:
logging.info('. GOOD S/N=%r', actual_snr_db)
keep_tweaking_snr = False
else:
actual_signal_dbm = self.context.client.wifi_signal_level
logging.info('. signal=%r', actual_signal_dbm)
if actual_signal_dbm > target_signal_dbm:
logging.info('. Signal > target value (%d)',
target_signal_dbm)
attenuation_db += actual_signal_dbm - target_signal_dbm
else:
keep_tweaking_snr = False
logging.info('Done')
def get_signal_to_noise(self, frequency):
"""Gets both the signal and the noise on the current connection.
@param frequency: noise needs to be read for a frequency.
@returns: signal and noise in dBm
"""
ping_ip = self.context.get_wifi_addr(ap_num=0)
ping_config = ping_runner.PingConfig(target_ip=ping_ip, count=1,
ignore_status=True,
ignore_result=True)
self.context.client.ping(ping_config) # Just to provide traffic.
signal_dbm = self.context.client.wifi_signal_level
noise_dbm = self.context.client.wifi_noise_level(frequency)
print '. signal: %r, noise: %r' % (signal_dbm, noise_dbm)
if noise_dbm is None:
return None
return signal_dbm - noise_dbm