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
5import logging
6import multiprocessing
7import re
8import select
9import time
10
11from autotest_lib.client.common_lib import error
12from autotest_lib.client.common_lib.cros.network import ping_runner
13from autotest_lib.client.common_lib.cros.network import xmlrpc_datatypes
14from autotest_lib.server import site_attenuator
15from autotest_lib.server.cros.network import hostap_config
16from autotest_lib.server.cros.network import rvr_test_base
17
18class 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
72class 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
183class 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