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, swrite, sexec) = select.select( 159 [self._pipe_reader], [], [], timeout) 160 if sread: 161 received_event = sread[0].recv() 162 received_event_log.append(received_event) 163 if re.search(expected_event, received_event): 164 logging.info('Found expected event: "%s"', 165 received_event.rstrip()) 166 if expected_events: 167 expected_event = expected_events.pop(0) 168 else: 169 expected_event = None 170 logging.info('Found ALL expected events') 171 break 172 else: # Timeout. 173 break 174 175 if expected_event: 176 logging.error('Never found expected event "%s". iw log:', 177 expected_event) 178 for event in received_event_log: 179 logging.error(event.rstrip()) 180 return False 181 return True 182 183 184class network_WiFi_RoamOnLowPower(rvr_test_base.RvRTestBase): 185 """Tests roaming to an AP when the old one's signal is too weak. 186 187 This test uses a dual-radio Stumpy as the AP and configures the radios to 188 broadcast two BSS's with different frequencies on the same SSID. The DUT 189 connects to the first radio, the test attenuates that radio, and the DUT 190 is supposed to roam to the second radio. 191 192 This test requires a particular configuration of test equipment: 193 194 +--------- StumpyCell/AP ----------+ 195 | chromeX.grover.hostY.router.cros | 196 | | 197 | [Radio 0] [Radio 1] | 198 +--------A-----B----C-----D--------+ 199 +------ BeagleBone ------+ | | | | 200 | chromeX.grover.hostY. | | X | X 201 | attenuator.cros [Port0]-[attenuator] | 202 | [Port1]----- | ----[attenuator] 203 | [Port2]-X | | 204 | [Port3]-X +-----+ | 205 | | | | 206 +------------------------+ | | 207 +--------------E----F--------------+ 208 | [Radio 0] | 209 | | 210 | chromeX.grover.hostY.cros | 211 +-------------- DUT ---------------+ 212 213 Where antennas A, C, and E are the primary antennas for AP/radio0, 214 AP/radio1, and DUT/radio0, respectively; and antennas B, D, and F are the 215 auxilliary antennas for AP/radio0, AP/radio1, and DUT/radio0, 216 respectively. The BeagleBone controls 2 attenuators that are connected 217 to the primary antennas of AP/radio0 and 1 which are fed into the primary 218 and auxilliary antenna ports of DUT/radio 0. Ports 2 and 3 of the 219 BeagleBone as well as the auxillary antennae of AP/radio0 and 1 are 220 terminated. 221 222 This arrangement ensures that the attenuator port numbers are assigned to 223 the primary radio, first, and the secondary radio, second. If this happens, 224 the ports will be numbered in the order in which the AP's channels are 225 configured (port 0 is first, port 1 is second, etc.). 226 227 This test is a de facto test that the ports are configured in that 228 arrangement since swapping Port0 and Port1 would cause us to attenuate the 229 secondary radio, providing no impetus for the DUT to switch radios and 230 causing the test to fail to connect at radio 1's frequency. 231 232 """ 233 234 version = 1 235 236 FREQUENCY_0 = 2412 237 FREQUENCY_1 = 2462 238 PORT_0 = 0 # Port created first (on FREQUENCY_0) 239 PORT_1 = 1 # Port created second (on FREQUENCY_1) 240 241 # Supplicant's signal to noise threshold for roaming. When noise is 242 # measurable and S/N is less than the threshold, supplicant will attempt 243 # to roam. We're setting the roam threshold (and setting it so high -- 244 # it's usually 18) because some of the DUTs we're using have a hard time 245 # measuring signals below -55 dBm. A threshold of 40 roams when the 246 # signal is about -50 dBm (since the noise tends to be around -89). 247 ABSOLUTE_ROAM_THRESHOLD_DB = 40 248 249 250 def run_once(self): 251 """Test body.""" 252 self.context.client.clear_supplicant_blacklist() 253 254 with self.context.client.roam_threshold( 255 self.ABSOLUTE_ROAM_THRESHOLD_DB): 256 logging.info('- Configure first AP & connect') 257 self.context.configure(hostap_config.HostapConfig( 258 frequency=self.FREQUENCY_0, 259 mode=hostap_config.HostapConfig.MODE_11G)) 260 router_ssid = self.context.router.get_ssid() 261 self.context.assert_connect_wifi(xmlrpc_datatypes. 262 AssociationParameters( 263 ssid=router_ssid)) 264 self.context.assert_ping_from_dut() 265 266 # Setup background scan configuration to set a signal level, below 267 # which, supplicant will scan (3dB below the current level). We 268 # must reconnect for these parameters to take effect. 269 logging.info('- Set background scan level') 270 bgscan_config = xmlrpc_datatypes.BgscanConfiguration( 271 method='simple', 272 signal=self.context.client.wifi_signal_level - 3) 273 self.context.client.shill.disconnect(router_ssid) 274 self.context.assert_connect_wifi( 275 xmlrpc_datatypes.AssociationParameters( 276 ssid=router_ssid, bgscan_config=bgscan_config)) 277 278 logging.info('- Configure second AP') 279 self.context.configure(hostap_config.HostapConfig( 280 ssid=router_ssid, 281 frequency=self.FREQUENCY_1, 282 mode=hostap_config.HostapConfig.MODE_11G), 283 multi_interface=True) 284 285 launch_iw_event = LaunchIwEvent('iw', 286 self.context.client.host, 287 timeout_seconds=60.0) 288 289 logging.info('- Drop the power on the first AP') 290 291 self.set_signal_to_force_roam(port=self.PORT_0, 292 frequency=self.FREQUENCY_0) 293 294 # Verify that the low signal event is generated, that supplicant 295 # scans as a result (or, at least, that supplicant scans after the 296 # threshold is passed), and that it connects to something. 297 logging.info('- Wait for RSSI threshold drop, scan, and connect') 298 if not launch_iw_event.wait_for_events(['RSSI went below threshold', 299 'scan started', 300 'connected to']): 301 raise error.TestFail('Did not find all expected events') 302 303 logging.info('- Wait for a connection on the second AP') 304 # Instead of explicitly connecting, just wait to see if the DUT 305 # connects to the second AP by itself 306 self.context.wait_for_connection(ssid=router_ssid, 307 freq=self.FREQUENCY_1, ap_num=1) 308 309 # Clean up. 310 self.context.router.deconfig() 311 312 313 def set_signal_to_force_roam(self, port, frequency): 314 """Adjust the AP attenuation to force the DUT to roam. 315 316 wpa_supplicant (v2.0-devel) decides when to roam based on a number of 317 factors even when we're only interested in the scenario when the roam 318 is instigated by an RSSI drop. The gates for roaming differ between 319 systems that have drivers that measure noise and those that don't. If 320 the driver reports noise, the S/N of both the current BSS and the 321 target BSS is capped at 30 and then the following conditions must be 322 met: 323 324 1) The S/N of the current AP must be below supplicant's roam 325 threshold. 326 2) The S/N of the roam target must be more than 3dB larger than 327 that of the current BSS. 328 329 If the driver does not report noise, the following condition must be 330 met: 331 332 3) The roam target's signal must be above the current BSS's signal 333 by a signal-dependent value (that value doesn't currently go 334 higher than 5). 335 336 This would all be enough complication. Unfortunately, the DUT's signal 337 measurement hardware has typically not been optimized for accurate 338 measurement throughout the signal range. Based on some testing 339 (crbug:295752), it was discovered that the DUT's measurements of signal 340 levels somewhere below -50dBm show values greater than the actual signal 341 and with quite a bit of variance. Since wpa_supplicant uses this same 342 mechanism to read its levels, this code must iterate to find values that 343 will reliably trigger supplicant to roam to the second AP. 344 345 It was also shown that some MIMO DUTs send different signal levels to 346 their two radios (testing has shown this to be somewhere around 5dB to 347 7dB). 348 349 @param port: the beaglebone port that is desired to be attenuated. 350 @param frequency: noise needs to be read for a frequency. 351 352 """ 353 # wpa_supplicant calls an S/N of 30 dB "quite good signal" and caps the 354 # S/N at this level for the purposes of roaming calculations. We'll do 355 # the same (since we're trying to instigate behavior in supplicant). 356 GREAT_SNR = 30 357 358 # The difference between the S/Ns of APs from 2), above. 359 MIN_AP_SIGNAL_DIFF_FOR_ROAM_DB = 3 360 361 # The maximum delta for a system that doesn't measure noise, from 3), 362 # above. 363 MIN_NOISELESS_SIGNAL_DIFF_FOR_ROAM_DB = 5 364 365 # Adds a clear margin to attenuator levels to make sure that we 366 # attenuate enough to do the job in light of signal and noise levels 367 # that bounce around. This value was reached empirically and further 368 # tweaking may be necessary if this test gets flaky. 369 SIGNAL_TO_NOISE_MARGIN_DB = 3 370 371 # The measured difference between the radios on one of our APs. 372 # TODO(wdg): dynamically measure the difference between the AP's radios 373 # (crbug:307678). 374 TEST_HW_SIGNAL_DELTA_DB = 7 375 376 # wpa_supplicant's roaming algorithm differs between systems that can 377 # measure noise and those that can't. This code tracks those 378 # differences. 379 actual_signal_dbm = self.context.client.wifi_signal_level 380 actual_noise_dbm = self.context.client.wifi_noise_level(frequency) 381 logging.info('Radio 0 signal: %r, noise: %r', actual_signal_dbm, 382 actual_noise_dbm) 383 if actual_noise_dbm is not None: 384 system_measures_noise = True 385 actual_snr_db = actual_signal_dbm - actual_noise_dbm 386 radio1_snr_db = actual_snr_db - TEST_HW_SIGNAL_DELTA_DB 387 388 # Supplicant will cap any S/N measurement used for roaming at 389 # GREAT_SNR so we'll do the same. 390 if radio1_snr_db > GREAT_SNR: 391 radio1_snr_db = GREAT_SNR 392 393 # In order to roam, the S/N of radio 0 must be both less than 3db 394 # below radio1 and less than the roam threshold. 395 logging.info('Radio 1 S/N = %d', radio1_snr_db) 396 delta_snr_threshold_db = (radio1_snr_db - 397 MIN_AP_SIGNAL_DIFF_FOR_ROAM_DB) 398 if (delta_snr_threshold_db < self.ABSOLUTE_ROAM_THRESHOLD_DB): 399 target_snr_db = delta_snr_threshold_db 400 logging.info('Target S/N = %d (delta algorithm)', 401 target_snr_db) 402 else: 403 target_snr_db = self.ABSOLUTE_ROAM_THRESHOLD_DB 404 logging.info('Target S/N = %d (threshold algorithm)', 405 target_snr_db) 406 407 # Add some margin. 408 target_snr_db -= SIGNAL_TO_NOISE_MARGIN_DB 409 attenuation_db = actual_snr_db - target_snr_db 410 logging.info('Noise: target S/N=%d attenuation=%r', 411 target_snr_db, attenuation_db) 412 else: 413 system_measures_noise = False 414 # On a system that doesn't measure noise, supplicant needs the 415 # signal from radio 0 to be less than that of radio 1 minus a fixed 416 # delta value. While we're here, subtract additional margin from 417 # the target value. 418 target_signal_dbm = (actual_signal_dbm - TEST_HW_SIGNAL_DELTA_DB - 419 MIN_NOISELESS_SIGNAL_DIFF_FOR_ROAM_DB - 420 SIGNAL_TO_NOISE_MARGIN_DB) 421 attenuation_db = actual_signal_dbm - target_signal_dbm 422 logging.info('No noise: target_signal=%r, attenuation=%r', 423 target_signal_dbm, attenuation_db) 424 425 # Attenuate, measure S/N, repeat (due to flaky measurments) until S/N is 426 # where we want it. 427 keep_tweaking_snr = True 428 while keep_tweaking_snr: 429 # Keep attenuation values below the attenuator's maximum. 430 if attenuation_db > (site_attenuator.Attenuator. 431 MAX_VARIABLE_ATTENUATION): 432 attenuation_db = (site_attenuator.Attenuator. 433 MAX_VARIABLE_ATTENUATION) 434 logging.info('Applying attenuation=%r', attenuation_db) 435 self.context.attenuator.set_variable_attenuation_on_port( 436 port, attenuation_db) 437 if attenuation_db >= (site_attenuator.Attenuator. 438 MAX_VARIABLE_ATTENUATION): 439 logging.warning('. NOTICE: Attenuation is at maximum value') 440 keep_tweaking_snr = False 441 elif system_measures_noise: 442 actual_snr_db = self.get_signal_to_noise(frequency) 443 if actual_snr_db > target_snr_db: 444 logging.info('. S/N (%d) > target value (%d)', 445 actual_snr_db, target_snr_db) 446 attenuation_db += actual_snr_db - target_snr_db 447 else: 448 logging.info('. GOOD S/N=%r', actual_snr_db) 449 keep_tweaking_snr = False 450 else: 451 actual_signal_dbm = self.context.client.wifi_signal_level 452 logging.info('. signal=%r', actual_signal_dbm) 453 if actual_signal_dbm > target_signal_dbm: 454 logging.info('. Signal > target value (%d)', 455 target_signal_dbm) 456 attenuation_db += actual_signal_dbm - target_signal_dbm 457 else: 458 keep_tweaking_snr = False 459 460 logging.info('Done') 461 462 463 def get_signal_to_noise(self, frequency): 464 """Gets both the signal and the noise on the current connection. 465 466 @param frequency: noise needs to be read for a frequency. 467 @returns: signal and noise in dBm 468 469 """ 470 ping_ip = self.context.get_wifi_addr(ap_num=0) 471 ping_config = ping_runner.PingConfig(target_ip=ping_ip, count=1, 472 ignore_status=True, 473 ignore_result=True) 474 self.context.client.ping(ping_config) # Just to provide traffic. 475 signal_dbm = self.context.client.wifi_signal_level 476 noise_dbm = self.context.client.wifi_noise_level(frequency) 477 print '. signal: %r, noise: %r' % (signal_dbm, noise_dbm) 478 if noise_dbm is None: 479 return None 480 return signal_dbm - noise_dbm 481