• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.')