1#!/usr/bin/env python3 2# 3# Copyright 2020 - The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the 'License'); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an 'AS IS' BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17import collections.abc 18import copy 19import fcntl 20import importlib 21import os 22import selenium 23import splinter 24import time 25from acts import logger 26 27BROWSER_WAIT_SHORT = 1 28BROWSER_WAIT_MED = 3 29BROWSER_WAIT_LONG = 30 30BROWSER_WAIT_EXTRA_LONG = 60 31 32 33def create(configs): 34 """Factory method for retail AP class. 35 36 Args: 37 configs: list of dicts containing ap settings. ap settings must contain 38 the following: brand, model, ip_address, username and password 39 """ 40 SUPPORTED_APS = { 41 ('Netgear', 'R7000'): { 42 'name': 'NetgearR7000AP', 43 'package': 'netgear_r7000' 44 }, 45 ('Netgear', 'R7000NA'): { 46 'name': 'NetgearR7000NAAP', 47 'package': 'netgear_r7000' 48 }, 49 ('Netgear', 'R7500'): { 50 'name': 'NetgearR7500AP', 51 'package': 'netgear_r7500' 52 }, 53 ('Netgear', 'R7500NA'): { 54 'name': 'NetgearR7500NAAP', 55 'package': 'netgear_r7500' 56 }, 57 ('Netgear', 'R7800'): { 58 'name': 'NetgearR7800AP', 59 'package': 'netgear_r7800' 60 }, 61 ('Netgear', 'R8000'): { 62 'name': 'NetgearR8000AP', 63 'package': 'netgear_r8000' 64 }, 65 ('Netgear', 'RAX80'): { 66 'name': 'NetgearRAX80AP', 67 'package': 'netgear_rax80' 68 }, 69 ('Netgear', 'RAX120'): { 70 'name': 'NetgearRAX120AP', 71 'package': 'netgear_rax120' 72 }, 73 ('Netgear', 'RAX200'): { 74 'name': 'NetgearRAX200AP', 75 'package': 'netgear_rax200' 76 }, 77 ('Netgear', 'RAXE500'): { 78 'name': 'NetgearRAXE500AP', 79 'package': 'netgear_raxe500' 80 }, 81 ('Brcm', 'Reference'): { 82 'name': 'BrcmRefAP', 83 'package': 'brcm_ref' 84 }, 85 ('Google', 'Wifi'): { 86 'name': 'GoogleWifiAP', 87 'package': 'google_wifi' 88 }, 89 } 90 objs = [] 91 for config in configs: 92 ap_id = (config['brand'], config['model']) 93 if ap_id not in SUPPORTED_APS: 94 raise KeyError('Invalid retail AP brand and model combination.') 95 ap_class_dict = SUPPORTED_APS[ap_id] 96 ap_package = 'acts_contrib.test_utils.wifi.wifi_retail_ap.{}'.format( 97 ap_class_dict['package']) 98 ap_package = importlib.import_module(ap_package) 99 ap_class = getattr(ap_package, ap_class_dict['name']) 100 objs.append(ap_class(config)) 101 return objs 102 103 104def destroy(objs): 105 for obj in objs: 106 obj.teardown() 107 108 109class BlockingBrowser(splinter.driver.webdriver.chrome.WebDriver): 110 """Class that implements a blocking browser session on top of selenium. 111 112 The class inherits from and builds upon splinter/selenium's webdriver class 113 and makes sure that only one such webdriver is active on a machine at any 114 single time. The class ensures single session operation using a lock file. 115 The class is to be used within context managers (e.g. with statements) to 116 ensure locks are always properly released. 117 """ 118 def __init__(self, headless, timeout): 119 """Constructor for BlockingBrowser class. 120 121 Args: 122 headless: boolean to control visible/headless browser operation 123 timeout: maximum time allowed to launch browser 124 """ 125 self.log = logger.create_tagged_trace_logger('ChromeDriver') 126 self.chrome_options = splinter.driver.webdriver.chrome.Options() 127 self.chrome_options.add_argument('--no-proxy-server') 128 self.chrome_options.add_argument('--no-sandbox') 129 self.chrome_options.add_argument('--allow-running-insecure-content') 130 self.chrome_options.add_argument('--ignore-certificate-errors') 131 self.chrome_capabilities = selenium.webdriver.common.desired_capabilities.DesiredCapabilities.CHROME.copy( 132 ) 133 self.chrome_capabilities['acceptSslCerts'] = True 134 self.chrome_capabilities['acceptInsecureCerts'] = True 135 if headless: 136 self.chrome_options.add_argument('--headless') 137 self.chrome_options.add_argument('--disable-gpu') 138 self.lock_file_path = '/usr/local/bin/chromedriver' 139 self.timeout = timeout 140 141 def __enter__(self): 142 """Entry context manager for BlockingBrowser. 143 144 The enter context manager for BlockingBrowser attempts to lock the 145 browser file. If successful, it launches and returns a chromedriver 146 session. If an exception occurs while starting the browser, the lock 147 file is released. 148 """ 149 self.lock_file = open(self.lock_file_path, 'r') 150 start_time = time.time() 151 while time.time() < start_time + self.timeout: 152 try: 153 fcntl.flock(self.lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) 154 except BlockingIOError: 155 time.sleep(BROWSER_WAIT_SHORT) 156 continue 157 try: 158 self.driver = selenium.webdriver.Chrome( 159 options=self.chrome_options, 160 desired_capabilities=self.chrome_capabilities) 161 self.element_class = splinter.driver.webdriver.WebDriverElement 162 self._cookie_manager = splinter.driver.webdriver.cookie_manager.CookieManager( 163 self.driver) 164 super(splinter.driver.webdriver.chrome.WebDriver, 165 self).__init__(2) 166 return super(BlockingBrowser, self).__enter__() 167 except: 168 fcntl.flock(self.lock_file, fcntl.LOCK_UN) 169 self.lock_file.close() 170 raise RuntimeError('Error starting browser. ' 171 'Releasing lock file.') 172 raise TimeoutError('Could not start chrome browser in time.') 173 174 def __exit__(self, exc_type, exc_value, traceback): 175 """Exit context manager for BlockingBrowser. 176 177 The exit context manager simply calls the parent class exit and 178 releases the lock file. 179 """ 180 try: 181 super(BlockingBrowser, self).__exit__(exc_type, exc_value, 182 traceback) 183 except: 184 raise RuntimeError('Failed to quit browser. Releasing lock file.') 185 finally: 186 fcntl.flock(self.lock_file, fcntl.LOCK_UN) 187 self.lock_file.close() 188 189 def restart(self): 190 """Method to restart browser session without releasing lock file.""" 191 self.quit() 192 self.__enter__() 193 194 def visit_persistent(self, 195 url, 196 page_load_timeout, 197 num_tries, 198 backup_url='about:blank', 199 check_for_element=None): 200 """Method to visit webpages and retry upon failure. 201 202 The function visits a URL and checks that the resulting URL matches 203 the intended URL, i.e. no redirects have happened 204 205 Args: 206 url: the intended url 207 page_load_timeout: timeout for page visits 208 num_tries: number of tries before url is declared unreachable 209 backup_url: url to visit if first url is not reachable. This can be 210 used to simply refresh the browser and try again or to re-login to 211 the AP 212 check_for_element: element id to check for existence on page 213 """ 214 self.driver.set_page_load_timeout(page_load_timeout) 215 for idx in range(num_tries): 216 try: 217 self.visit(url) 218 except: 219 self.restart() 220 221 page_reached = self.url.split('/')[-1] == url.split('/')[-1] 222 if check_for_element: 223 time.sleep(BROWSER_WAIT_MED) 224 element = self.find_by_id(check_for_element) 225 if not element: 226 page_reached = 0 227 if page_reached: 228 break 229 else: 230 try: 231 self.visit(backup_url) 232 except: 233 self.restart() 234 235 if idx == num_tries - 1: 236 self.log.error('URL unreachable. Current URL: {}'.format( 237 self.url)) 238 raise RuntimeError('URL unreachable.') 239 240 241class WifiRetailAP(object): 242 """Base class implementation for retail ap. 243 244 Base class provides functions whose implementation is shared by all aps. 245 If some functions such as set_power not supported by ap, checks will raise 246 exceptions. 247 """ 248 def __init__(self, ap_settings): 249 self.ap_settings = ap_settings.copy() 250 self.log = logger.create_tagged_trace_logger('AccessPoint|{}'.format( 251 self._get_control_ip_address())) 252 # Capabilities variable describing AP capabilities 253 self.capabilities = { 254 'interfaces': [], 255 'channels': {}, 256 'modes': {}, 257 'default_mode': None 258 } 259 for interface in self.capabilities['interfaces']: 260 self.ap_settings.setdefault(interface, {}) 261 # Lock AP 262 if self.ap_settings.get('lock_ap', 0): 263 self.lock_timeout = self.ap_settings.get('lock_timeout', 3600) 264 self._lock_ap() 265 266 def teardown(self): 267 """Function to perform destroy operations.""" 268 if self.ap_settings.get('lock_ap', 0): 269 self._unlock_ap() 270 271 def reset(self): 272 """Function that resets AP. 273 274 Function implementation is AP dependent and intended to perform any 275 necessary reset operations as part of controller destroy. 276 """ 277 pass 278 279 def read_ap_settings(self): 280 """Function that reads current ap settings. 281 282 Function implementation is AP dependent and thus base class raises exception 283 if function not implemented in child class. 284 """ 285 raise NotImplementedError 286 287 def validate_ap_settings(self): 288 """Function to validate ap settings. 289 290 This function compares the actual ap settings read from the web GUI 291 with the assumed settings saved in the AP object. When called after AP 292 configuration, this method helps ensure that our configuration was 293 successful. 294 Note: Calling this function updates the stored ap_settings 295 296 Raises: 297 ValueError: If read AP settings do not match stored settings. 298 """ 299 assumed_ap_settings = copy.deepcopy(self.ap_settings) 300 actual_ap_settings = self.read_ap_settings() 301 302 if assumed_ap_settings != actual_ap_settings: 303 self.log.warning( 304 'Discrepancy in AP settings. Some settings may have been overwritten.' 305 ) 306 307 def configure_ap(self, **config_flags): 308 """Function that configures ap based on values of ap_settings. 309 310 Function implementation is AP dependent and thus base class raises exception 311 if function not implemented in child class. 312 313 Args: 314 config_flags: optional configuration flags 315 """ 316 raise NotImplementedError 317 318 def set_region(self, region): 319 """Function that sets AP region. 320 321 This function sets the region for the AP. Note that this may overwrite 322 channel and bandwidth settings in cases where the new region does not 323 support the current wireless configuration. 324 325 Args: 326 region: string indicating AP region 327 """ 328 if region != self.ap_settings['region']: 329 self.log.warning( 330 'Updating region may overwrite wireless settings.') 331 setting_to_update = {'region': region} 332 self.update_ap_settings(setting_to_update) 333 334 def set_radio_on_off(self, network, status): 335 """Function that turns the radio on or off. 336 337 Args: 338 network: string containing network identifier (2G, 5G_1, 5G_2) 339 status: boolean indicating on or off (0: off, 1: on) 340 """ 341 setting_to_update = {network: {'status': int(status)}} 342 self.update_ap_settings(setting_to_update) 343 344 def set_ssid(self, network, ssid): 345 """Function that sets network SSID. 346 347 Args: 348 network: string containing network identifier (2G, 5G_1, 5G_2) 349 ssid: string containing ssid 350 """ 351 setting_to_update = {network: {'ssid': str(ssid)}} 352 self.update_ap_settings(setting_to_update) 353 354 def set_channel(self, network, channel): 355 """Function that sets network channel. 356 357 Args: 358 network: string containing network identifier (2G, 5G_1, 5G_2) 359 channel: string or int containing channel 360 """ 361 if channel not in self.capabilities['channels'][network]: 362 self.log.error('Ch{} is not supported on {} interface.'.format( 363 channel, network)) 364 setting_to_update = {network: {'channel': channel}} 365 self.update_ap_settings(setting_to_update) 366 367 def set_bandwidth(self, network, bandwidth): 368 """Function that sets network bandwidth/mode. 369 370 Args: 371 network: string containing network identifier (2G, 5G_1, 5G_2) 372 bandwidth: string containing mode, e.g. 11g, VHT20, VHT40, VHT80. 373 """ 374 if 'bw' in bandwidth: 375 bandwidth = bandwidth.replace('bw', 376 self.capabilities['default_mode']) 377 elif isinstance(bandwidth, int): 378 bandwidth = str(bandwidth) + self.capabilities['default_mode'] 379 if bandwidth not in self.capabilities['modes'][network]: 380 self.log.error('{} mode is not supported on {} interface.'.format( 381 bandwidth, network)) 382 setting_to_update = {network: {'bandwidth': bandwidth}} 383 self.update_ap_settings(setting_to_update) 384 385 def set_channel_and_bandwidth(self, network, channel, bandwidth): 386 """Function that sets network bandwidth/mode and channel. 387 388 Args: 389 network: string containing network identifier (2G, 5G_1, 5G_2) 390 channel: string containing desired channel 391 bandwidth: string containing mode, e.g. 11g, VHT20, VHT40, VHT80. 392 """ 393 if 'bw' in bandwidth: 394 bandwidth = bandwidth.replace('bw', 395 self.capabilities['default_mode']) 396 elif isinstance(bandwidth, int): 397 bandwidth = str(bandwidth) + self.capabilities['default_mode'] 398 if bandwidth not in self.capabilities['modes'][network]: 399 self.log.error('{} mode is not supported on {} interface.'.format( 400 bandwidth, network)) 401 if channel not in self.capabilities['channels'][network]: 402 self.log.error('Ch{} is not supported on {} interface.'.format( 403 channel, network)) 404 setting_to_update = { 405 network: { 406 'bandwidth': bandwidth, 407 'channel': channel 408 } 409 } 410 self.update_ap_settings(setting_to_update) 411 412 def set_power(self, network, power): 413 """Function that sets network transmit power. 414 415 Args: 416 network: string containing network identifier (2G, 5G_1, 5G_2) 417 power: string containing power level, e.g., 25%, 100% 418 """ 419 if 'power' not in self.ap_settings[network].keys(): 420 self.log.error( 421 'Cannot configure power on {} interface.'.format(network)) 422 setting_to_update = {network: {'power': power}} 423 self.update_ap_settings(setting_to_update) 424 425 def set_security(self, network, security_type, *password): 426 """Function that sets network security setting and password. 427 428 Args: 429 network: string containing network identifier (2G, 5G_1, 5G_2) 430 security: string containing security setting, e.g., WPA2-PSK 431 password: optional argument containing password 432 """ 433 if (len(password) == 1) and (type(password[0]) == str): 434 setting_to_update = { 435 network: { 436 'security_type': str(security_type), 437 'password': str(password[0]) 438 } 439 } 440 else: 441 setting_to_update = { 442 network: { 443 'security_type': str(security_type) 444 } 445 } 446 self.update_ap_settings(setting_to_update) 447 448 def set_rate(self): 449 """Function that configures rate used by AP. 450 451 Function implementation is not supported by most APs and thus base 452 class raises exception if function not implemented in child class. 453 """ 454 raise NotImplementedError 455 456 def _update_settings_dict(self, 457 settings, 458 updates, 459 updates_requested=False, 460 status_toggle_flag=False): 461 new_settings = copy.deepcopy(settings) 462 for key, value in updates.items(): 463 if key not in new_settings.keys(): 464 raise KeyError('{} is an invalid settings key.'.format(key)) 465 elif isinstance(value, collections.abc.Mapping): 466 new_settings[ 467 key], updates_requested, status_toggle_flag = self._update_settings_dict( 468 new_settings.get(key, {}), value, updates_requested, 469 status_toggle_flag) 470 elif new_settings[key] != value: 471 new_settings[key] = value 472 updates_requested = True 473 if 'status' in key: 474 status_toggle_flag = True 475 return new_settings, updates_requested, status_toggle_flag 476 477 def update_ap_settings(self, dict_settings={}, **named_settings): 478 """Function to update settings of existing AP. 479 480 Function copies arguments into ap_settings and calls configure_retail_ap 481 to apply them. 482 483 Args: 484 *dict_settings accepts single dictionary of settings to update 485 **named_settings accepts named settings to update 486 Note: dict and named_settings cannot contain the same settings. 487 """ 488 settings_to_update = dict(dict_settings, **named_settings) 489 if len(settings_to_update) != len(dict_settings) + len(named_settings): 490 raise KeyError('The following keys were passed twice: {}'.format( 491 (set(dict_settings.keys()).intersection( 492 set(named_settings.keys()))))) 493 494 self.ap_settings, updates_requested, status_toggle_flag = self._update_settings_dict( 495 self.ap_settings, settings_to_update) 496 497 if updates_requested: 498 self.configure_ap(status_toggled=status_toggle_flag) 499 500 def band_lookup_by_channel(self, channel): 501 """Function that gives band name by channel number. 502 503 Args: 504 channel: channel number to lookup 505 Returns: 506 band: name of band which this channel belongs to on this ap, False 507 if not supported 508 """ 509 for key, value in self.capabilities['channels'].items(): 510 if channel in value: 511 return key 512 return False 513 514 def _get_control_ip_address(self): 515 """Function to get AP's Control Interface IP address.""" 516 if 'ssh_config' in self.ap_settings.keys(): 517 return self.ap_settings['ssh_config']['host'] 518 else: 519 return self.ap_settings['ip_address'] 520 521 def _lock_ap(self): 522 """Function to lock the ap while tests are running.""" 523 self.lock_file_path = '/tmp/{}_{}_{}.lock'.format( 524 self.ap_settings['brand'], self.ap_settings['model'], 525 self._get_control_ip_address()) 526 if not os.path.exists(self.lock_file_path): 527 with open(self.lock_file_path, 'w'): 528 pass 529 self.lock_file = open(self.lock_file_path, 'r') 530 start_time = time.time() 531 self.log.info('Trying to acquire AP lock.') 532 while time.time() < start_time + self.lock_timeout: 533 try: 534 fcntl.flock(self.lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) 535 except BlockingIOError: 536 time.sleep(BROWSER_WAIT_SHORT) 537 continue 538 self.log.info('AP lock acquired.') 539 return 540 raise RuntimeError('Could not lock AP in time.') 541 542 def _unlock_ap(self): 543 """Function to unlock the AP when tests are done.""" 544 self.log.info('Releasing AP lock.') 545 if hasattr(self, 'lock_file'): 546 try: 547 fcntl.flock(self.lock_file, fcntl.LOCK_UN) 548 self.lock_file.close() 549 self.log.info('Succussfully released AP lock file.') 550 except: 551 raise RuntimeError('Error occurred while unlocking AP.')