Home | History | Annotate | Download | only in network_WiFi_RoamOnLowPower
      1 # Copyright (c) 2013 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 
      5 import logging
      6 import multiprocessing
      7 import re
      8 import select
      9 import time
     10 
     11 from autotest_lib.client.common_lib import error
     12 from autotest_lib.client.common_lib.cros.network import ping_runner
     13 from autotest_lib.client.common_lib.cros.network import xmlrpc_datatypes
     14 from autotest_lib.server import site_attenuator
     15 from autotest_lib.server.cros.network import hostap_config
     16 from autotest_lib.server.cros.network import rvr_test_base
     17 
     18 class Reporter(object):
     19     """Object that forwards stdout from Host.run to a pipe.
     20 
     21     The |stdout_tee| parameter for Host.run() requires an object that looks
     22     like a Python built-in file.  In particular, it needs 'flush', which a
     23     multiprocessing.Connection (the object returned by multiprocessing.Pipe)
     24     doesn't have.  This wrapper provides that functionaly in order to allow a
     25     pipe to be the target of a stdout_tee.
     26 
     27     """
     28 
     29     def __init__(self, write_pipe):
     30         """Initializes reporter.
     31 
     32         @param write_pipe: the place to send output.
     33 
     34         """
     35         self._write_pipe = write_pipe
     36 
     37 
     38     def flush(self):
     39         """Flushes the output - not used by the pipe."""
     40         pass
     41 
     42 
     43     def close(self):
     44         """Closes the pipe."""
     45         return self._write_pipe.close()
     46 
     47 
     48     def fileno(self):
     49         """Returns the file number of the pipe."""
     50         return self._write_pipe.fileno()
     51 
     52 
     53     def write(self, string):
     54         """Write to the pipe.
     55 
     56         @param string: the string to write to the pipe.
     57 
     58         """
     59         self._write_pipe.send(string)
     60 
     61 
     62     def writelines(self, sequence):
     63         """Write a number of lines to the pipe.
     64 
     65         @param sequence: the array of lines to be written.
     66 
     67         """
     68         for string in sequence:
     69             self._write_pipe.send(string)
     70 
     71 
     72 class LaunchIwEvent(object):
     73     """Calls 'iw event' and searches for a list of events in its output.
     74 
     75     This class provides a framework for launching 'iw event' in its own
     76     process and searching its output for an ordered list of events expressed
     77     as regular expressions.
     78 
     79     Expected to be called as follows:
     80         launch_iw_event = LaunchIwEvent('iw',
     81                                         self.context.client.host,
     82                                         timeout_seconds=60.0)
     83         # Do things that cause nl80211 traffic
     84 
     85         # Now, wait for the results you want.
     86         if not launch_iw_event.wait_for_events(['RSSI went below threshold',
     87                                                 'scan started',
     88                                                 # ...
     89                                                 'connected to']):
     90             raise error.TestFail('Did not find all expected events')
     91 
     92     """
     93     # A timeout from Host.run(timeout) kills the process and that takes a
     94     # few seconds.  Therefore, we need to add some margin to the select
     95     # timeout (which will kill the process if Host.run(timeout) fails for some
     96     # reason).
     97     TIMEOUT_MARGIN_SECONDS = 5
     98 
     99     def __init__(self, iw_command, dut, timeout_seconds):
    100         """Launches 'iw event' process with communication channel for output
    101 
    102         @param dut: Host object for the dut
    103         @param timeout_seconds: timeout for 'iw event' (since it never
    104         returns)
    105 
    106         """
    107         self._iw_command = iw_command
    108         self._dut = dut
    109         self._timeout_seconds = timeout_seconds
    110         self._pipe_reader, pipe_writer = multiprocessing.Pipe()
    111         self._iw_event = multiprocessing.Process(target=self.do_iw,
    112                                                  args=(pipe_writer,
    113                                                        self._timeout_seconds,))
    114         self._iw_event.start()
    115 
    116 
    117     def do_iw(self, connection, timeout_seconds):
    118         """Runs 'iw event'
    119 
    120         iw results are passed back, on the fly, through a supplied connection
    121         object.  The process terminates itself after a specified timeout.
    122 
    123         @param connection: a Connection object to which results are written.
    124         @param timeout_seconds: number of seconds before 'iw event' is killed.
    125 
    126         """
    127         reporter = Reporter(connection)
    128         # ignore_timeout just ignores the _exception_; the timeout is still
    129         # valid.
    130         self._dut.run('%s event' % self._iw_command,
    131                       timeout=timeout_seconds,
    132                       stdout_tee=reporter,
    133                       ignore_timeout=True)
    134 
    135 
    136     def wait_for_events(self, expected_events):
    137         """Waits for 'expected_events' (in order) from iw.
    138 
    139         @param expected_events: a list of strings that are regular expressions.
    140             This method searches for the each expression, in the order that they
    141             appear in |expected_events|, in the stream of output from iw. x
    142 
    143         @returns: True if all events were found.  False, otherwise.
    144 
    145         """
    146         if not expected_events:
    147             logging.error('No events')
    148             return False
    149 
    150         expected_event = expected_events.pop(0)
    151         done_time = (time.time() + self._timeout_seconds +
    152                      LaunchIwEvent.TIMEOUT_MARGIN_SECONDS)
    153         received_event_log = []
    154         while expected_event:
    155             timeout = done_time - time.time()
    156             if timeout <= 0:
    157                 break
    158             (sread, _, __) = select.select([self._pipe_reader], [], [], timeout)
    159             if sread:
    160                 received_event = sread[0].recv()
    161                 received_event_log.append(received_event)
    162                 if re.search(expected_event, received_event):
    163                     logging.info('Found expected event: "%s"',
    164                                  received_event.rstrip())
    165                     if expected_events:
    166                         expected_event = expected_events.pop(0)
    167                     else:
    168                         expected_event = None
    169                         logging.info('Found ALL expected events')
    170                         break
    171             else:  # Timeout.
    172                 break
    173 
    174         if expected_event:
    175             logging.error('Never found expected event "%s". iw log:',
    176                           expected_event)
    177             for event in received_event_log:
    178                 logging.error(event.rstrip())
    179             return False
    180         return True
    181 
    182 
    183 class network_WiFi_RoamOnLowPower(rvr_test_base.RvRTestBase):
    184     """Tests roaming to an AP when the old one's signal is too weak.
    185 
    186     This test uses a dual-radio Stumpy as the AP and configures the radios to
    187     broadcast two BSS's with different frequencies on the same SSID.  The DUT
    188     connects to the first radio, the test attenuates that radio, and the DUT
    189     is supposed to roam to the second radio.
    190 
    191     This test requires a particular configuration of test equipment:
    192 
    193                                    +--------- StumpyCell/AP ----------+
    194                                    | chromeX.grover.hostY.router.cros |
    195                                    |                                  |
    196                                    |       [Radio 0]  [Radio 1]       |
    197                                    +--------A-----B----C-----D--------+
    198         +------ BeagleBone ------+          |     |    |     |
    199         | chromeX.grover.hostY.  |          |     X    |     X
    200         | attenuator.cros      [Port0]-[attenuator]    |
    201         |                      [Port1]----- | ----[attenuator]
    202         |                      [Port2]-X    |          |
    203         |                      [Port3]-X    +-----+    |
    204         |                        |                |    |
    205         +------------------------+                |    |
    206                                    +--------------E----F--------------+
    207                                    |             [Radio 0]            |
    208                                    |                                  |
    209                                    |    chromeX.grover.hostY.cros     |
    210                                    +-------------- DUT ---------------+
    211 
    212     Where antennas A, C, and E are the primary antennas for AP/radio0,
    213     AP/radio1, and DUT/radio0, respectively; and antennas B, D, and F are the
    214     auxilliary antennas for AP/radio0, AP/radio1, and DUT/radio0,
    215     respectively.  The BeagleBone controls 2 attenuators that are connected
    216     to the primary antennas of AP/radio0 and 1 which are fed into the primary
    217     and auxilliary antenna ports of DUT/radio 0.  Ports 2 and 3 of the
    218     BeagleBone as well as the auxillary antennae of AP/radio0 and 1 are
    219     terminated.
    220 
    221     This arrangement ensures that the attenuator port numbers are assigned to
    222     the primary radio, first, and the secondary radio, second.  If this happens,
    223     the ports will be numbered in the order in which the AP's channels are
    224     configured (port 0 is first, port 1 is second, etc.).
    225 
    226     This test is a de facto test that the ports are configured in that
    227     arrangement since swapping Port0 and Port1 would cause us to attenuate the
    228     secondary radio, providing no impetus for the DUT to switch radios and
    229     causing the test to fail to connect at radio 1's frequency.
    230 
    231     """
    232 
    233     version = 1
    234 
    235     FREQUENCY_0 = 2412
    236     FREQUENCY_1 = 2462
    237     PORT_0 = 0  # Port created first (on FREQUENCY_0)
    238     PORT_1 = 1  # Port created second (on FREQUENCY_1)
    239 
    240     # Supplicant's signal to noise threshold for roaming.  When noise is
    241     # measurable and S/N is less than the threshold, supplicant will attempt
    242     # to roam.  We're setting the roam threshold (and setting it so high --
    243     # it's usually 18) because some of the DUTs we're using have a hard time
    244     # measuring signals below -55 dBm.  A threshold of 40 roams when the
    245     # signal is about -50 dBm (since the noise tends to be around -89).
    246     ABSOLUTE_ROAM_THRESHOLD_DB = 40
    247 
    248 
    249     def run_once(self):
    250         """Test body."""
    251         self.context.client.clear_supplicant_blacklist()
    252 
    253         with self.context.client.roam_threshold(
    254                 self.ABSOLUTE_ROAM_THRESHOLD_DB):
    255             logging.info('- Configure first AP & connect')
    256             self.context.configure(hostap_config.HostapConfig(
    257                     frequency=self.FREQUENCY_0,
    258                     mode=hostap_config.HostapConfig.MODE_11G))
    259             router_ssid = self.context.router.get_ssid()
    260             self.context.assert_connect_wifi(xmlrpc_datatypes.
    261                                              AssociationParameters(
    262                     ssid=router_ssid))
    263             self.context.assert_ping_from_dut()
    264 
    265             # Setup background scan configuration to set a signal level, below
    266             # which, supplicant will scan (3dB below the current level).  We
    267             # must reconnect for these parameters to take effect.
    268             logging.info('- Set background scan level')
    269             bgscan_config = xmlrpc_datatypes.BgscanConfiguration(
    270                     method='simple',
    271                     signal=self.context.client.wifi_signal_level - 3)
    272             self.context.client.shill.disconnect(router_ssid)
    273             self.context.assert_connect_wifi(
    274                     xmlrpc_datatypes.AssociationParameters(
    275                     ssid=router_ssid, bgscan_config=bgscan_config))
    276 
    277             logging.info('- Configure second AP')
    278             self.context.configure(hostap_config.HostapConfig(
    279                     ssid=router_ssid,
    280                     frequency=self.FREQUENCY_1,
    281                     mode=hostap_config.HostapConfig.MODE_11G),
    282                                    multi_interface=True)
    283 
    284             launch_iw_event = LaunchIwEvent('iw',
    285                                             self.context.client.host,
    286                                             timeout_seconds=60.0)
    287 
    288             logging.info('- Drop the power on the first AP')
    289 
    290             self.set_signal_to_force_roam(port=self.PORT_0,
    291                                           frequency=self.FREQUENCY_0)
    292 
    293             # Verify that the low signal event is generated, that supplicant
    294             # scans as a result (or, at least, that supplicant scans after the
    295             # threshold is passed), and that it connects to something.
    296             logging.info('- Wait for RSSI threshold drop, scan, and connect')
    297             if not launch_iw_event.wait_for_events(['RSSI went below threshold',
    298                                                     'scan started',
    299                                                     'connected to']):
    300                 raise error.TestFail('Did not find all expected events')
    301 
    302             logging.info('- Wait for a connection on the second AP')
    303             # Instead of explicitly connecting, just wait to see if the DUT
    304             # connects to the second AP by itself
    305             self.context.wait_for_connection(ssid=router_ssid,
    306                                              freq=self.FREQUENCY_1, ap_num=1)
    307 
    308             # Clean up.
    309             self.context.router.deconfig()
    310 
    311 
    312     def set_signal_to_force_roam(self, port, frequency):
    313         """Adjust the AP attenuation to force the DUT to roam.
    314 
    315         wpa_supplicant (v2.0-devel) decides when to roam based on a number of
    316         factors even when we're only interested in the scenario when the roam
    317         is instigated by an RSSI drop.  The gates for roaming differ between
    318         systems that have drivers that measure noise and those that don't.  If
    319         the driver reports noise, the S/N of both the current BSS and the
    320         target BSS is capped at 30 and then the following conditions must be
    321         met:
    322 
    323             1) The S/N of the current AP must be below supplicant's roam
    324                threshold.
    325             2) The S/N of the roam target must be more than 3dB larger than
    326                that of the current BSS.
    327 
    328         If the driver does not report noise, the following condition must be
    329         met:
    330 
    331             3) The roam target's signal must be above the current BSS's signal
    332                by a signal-dependent value (that value doesn't currently go
    333                higher than 5).
    334 
    335         This would all be enough complication.  Unfortunately, the DUT's signal
    336         measurement hardware has typically not been optimized for accurate
    337         measurement throughout the signal range.  Based on some testing
    338         (crbug:295752), it was discovered that the DUT's measurements of signal
    339         levels somewhere below -50dBm show values greater than the actual signal
    340         and with quite a bit of variance.  Since wpa_supplicant uses this same
    341         mechanism to read its levels, this code must iterate to find values that
    342         will reliably trigger supplicant to roam to the second AP.
    343 
    344         It was also shown that some MIMO DUTs send different signal levels to
    345         their two radios (testing has shown this to be somewhere around 5dB to
    346         7dB).
    347 
    348         @param port: the beaglebone port that is desired to be attenuated.
    349         @param frequency: noise needs to be read for a frequency.
    350 
    351         """
    352         # wpa_supplicant calls an S/N of 30 dB "quite good signal" and caps the
    353         # S/N at this level for the purposes of roaming calculations.  We'll do
    354         # the same (since we're trying to instigate behavior in supplicant).
    355         GREAT_SNR = 30
    356 
    357         # The difference between the S/Ns of APs from 2), above.
    358         MIN_AP_SIGNAL_DIFF_FOR_ROAM_DB = 3
    359 
    360         # The maximum delta for a system that doesn't measure noise, from 3),
    361         # above.
    362         MIN_NOISELESS_SIGNAL_DIFF_FOR_ROAM_DB = 5
    363 
    364         # Adds a clear margin to attenuator levels to make sure that we
    365         # attenuate enough to do the job in light of signal and noise levels
    366         # that bounce around.  This value was reached empirically and further
    367         # tweaking may be necessary if this test gets flaky.
    368         SIGNAL_TO_NOISE_MARGIN_DB = 3
    369 
    370         # The measured difference between the radios on one of our APs.
    371         # TODO(wdg): dynamically measure the difference between the AP's radios
    372         # (crbug:307678).
    373         TEST_HW_SIGNAL_DELTA_DB = 7
    374 
    375         # wpa_supplicant's roaming algorithm differs between systems that can
    376         # measure noise and those that can't.  This code tracks those
    377         # differences.
    378         actual_signal_dbm = self.context.client.wifi_signal_level
    379         actual_noise_dbm = self.context.client.wifi_noise_level(frequency)
    380         logging.info('Radio 0 signal: %r, noise: %r', actual_signal_dbm,
    381                      actual_noise_dbm)
    382         if actual_noise_dbm is not None:
    383             system_measures_noise = True
    384             actual_snr_db = actual_signal_dbm - actual_noise_dbm
    385             radio1_snr_db = actual_snr_db - TEST_HW_SIGNAL_DELTA_DB
    386 
    387             # Supplicant will cap any S/N measurement used for roaming at
    388             # GREAT_SNR so we'll do the same.
    389             if radio1_snr_db > GREAT_SNR:
    390                 radio1_snr_db = GREAT_SNR
    391 
    392             # In order to roam, the S/N of radio 0 must be both less than 3db
    393             # below radio1 and less than the roam threshold.
    394             logging.info('Radio 1 S/N = %d', radio1_snr_db)
    395             delta_snr_threshold_db = (radio1_snr_db -
    396                                       MIN_AP_SIGNAL_DIFF_FOR_ROAM_DB)
    397             if (delta_snr_threshold_db < self.ABSOLUTE_ROAM_THRESHOLD_DB):
    398                 target_snr_db = delta_snr_threshold_db
    399                 logging.info('Target S/N = %d (delta algorithm)',
    400                              target_snr_db)
    401             else:
    402                 target_snr_db = self.ABSOLUTE_ROAM_THRESHOLD_DB
    403                 logging.info('Target S/N = %d (threshold algorithm)',
    404                              target_snr_db)
    405 
    406             # Add some margin.
    407             target_snr_db -= SIGNAL_TO_NOISE_MARGIN_DB
    408             attenuation_db = actual_snr_db - target_snr_db
    409             logging.info('Noise: target S/N=%d attenuation=%r',
    410                          target_snr_db, attenuation_db)
    411         else:
    412             system_measures_noise = False
    413             # On a system that doesn't measure noise, supplicant needs the
    414             # signal from radio 0 to be less than that of radio 1 minus a fixed
    415             # delta value.  While we're here, subtract additional margin from
    416             # the target value.
    417             target_signal_dbm = (actual_signal_dbm - TEST_HW_SIGNAL_DELTA_DB -
    418                                  MIN_NOISELESS_SIGNAL_DIFF_FOR_ROAM_DB -
    419                                  SIGNAL_TO_NOISE_MARGIN_DB)
    420             attenuation_db = actual_signal_dbm - target_signal_dbm
    421             logging.info('No noise: target_signal=%r, attenuation=%r',
    422                          target_signal_dbm, attenuation_db)
    423 
    424         # Attenuate, measure S/N, repeat (due to flaky measurments) until S/N is
    425         # where we want it.
    426         keep_tweaking_snr = True
    427         while keep_tweaking_snr:
    428             # Keep attenuation values below the attenuator's maximum.
    429             if attenuation_db > (site_attenuator.Attenuator.
    430                                  MAX_VARIABLE_ATTENUATION):
    431                 attenuation_db = (site_attenuator.Attenuator.
    432                                   MAX_VARIABLE_ATTENUATION)
    433             logging.info('Applying attenuation=%r', attenuation_db)
    434             self.context.attenuator.set_variable_attenuation_on_port(
    435                     port, attenuation_db)
    436             if attenuation_db >= (site_attenuator.Attenuator.
    437                                     MAX_VARIABLE_ATTENUATION):
    438                 logging.warning('. NOTICE: Attenuation is at maximum value')
    439                 keep_tweaking_snr = False
    440             elif system_measures_noise:
    441                 actual_snr_db = self.get_signal_to_noise(frequency)
    442                 if actual_snr_db > target_snr_db:
    443                     logging.info('. S/N (%d) > target value (%d)',
    444                                  actual_snr_db, target_snr_db)
    445                     attenuation_db += actual_snr_db - target_snr_db
    446                 else:
    447                     logging.info('. GOOD S/N=%r', actual_snr_db)
    448                     keep_tweaking_snr = False
    449             else:
    450                 actual_signal_dbm = self.context.client.wifi_signal_level
    451                 logging.info('. signal=%r', actual_signal_dbm)
    452                 if actual_signal_dbm > target_signal_dbm:
    453                     logging.info('. Signal > target value (%d)',
    454                                  target_signal_dbm)
    455                     attenuation_db += actual_signal_dbm - target_signal_dbm
    456                 else:
    457                     keep_tweaking_snr = False
    458 
    459         logging.info('Done')
    460 
    461 
    462     def get_signal_to_noise(self, frequency):
    463         """Gets both the signal and the noise on the current connection.
    464 
    465         @param frequency: noise needs to be read for a frequency.
    466         @returns: signal and noise in dBm
    467 
    468         """
    469         ping_ip = self.context.get_wifi_addr(ap_num=0)
    470         ping_config = ping_runner.PingConfig(target_ip=ping_ip, count=1,
    471                                              ignore_status=True,
    472                                              ignore_result=True)
    473         self.context.client.ping(ping_config)  # Just to provide traffic.
    474         signal_dbm = self.context.client.wifi_signal_level
    475         noise_dbm = self.context.client.wifi_noise_level(frequency)
    476         print '. signal: %r, noise: %r' % (signal_dbm, noise_dbm)
    477         if noise_dbm is None:
    478             return None
    479         return signal_dbm - noise_dbm
    480