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