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