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