1#!/usr/bin/env python3
2#
3#   Copyright 2017 Google, Inc.
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 logging
18import time
19import os
20from acts import utils
21from acts.libs.proc import job
22from acts.controllers.ap_lib import bridge_interface as bi
23from acts.test_utils.wifi import wifi_test_utils as wutils
24from bokeh.layouts import column, layout
25from bokeh.models import CustomJS, ColumnDataSource
26from bokeh.models import tools as bokeh_tools
27from bokeh.models.widgets import DataTable, TableColumn
28from bokeh.plotting import figure, output_file, save
29from acts.controllers.ap_lib import hostapd_security
30from acts.controllers.ap_lib import hostapd_ap_preset
31
32# http://www.secdev.org/projects/scapy/
33# On ubuntu, sudo pip3 install scapy
34import scapy.all as scapy
35
36GET_FROM_PHONE = 'get_from_dut'
37GET_FROM_AP = 'get_from_ap'
38ENABLED_MODULATED_DTIM = 'gEnableModulatedDTIM='
39MAX_MODULATED_DTIM = 'gMaxLIModulatedDTIM='
40
41
42def monsoon_data_plot(mon_info, monsoon_results, tag=''):
43    """Plot the monsoon current data using bokeh interactive plotting tool.
44
45    Plotting power measurement data with bokeh to generate interactive plots.
46    You can do interactive data analysis on the plot after generating with the
47    provided widgets, which make the debugging much easier. To realize that,
48    bokeh callback java scripting is used. View a sample html output file:
49    https://drive.google.com/open?id=0Bwp8Cq841VnpT2dGUUxLYWZvVjA
50
51    Args:
52        mon_info: obj with information of monsoon measurement, including
53            monsoon device object, measurement frequency, duration, etc.
54        monsoon_results: a MonsoonResult or list of MonsoonResult objects to
55                         to plot.
56        tag: an extra tag to append to the resulting filename.
57
58    Returns:
59        plot: the plotting object of bokeh, optional, will be needed if multiple
60           plots will be combined to one html file.
61        dt: the datatable object of bokeh, optional, will be needed if multiple
62           datatables will be combined to one html file.
63    """
64    if not isinstance(monsoon_results, list):
65        monsoon_results = [monsoon_results]
66    logging.info('Plotting the power measurement data.')
67
68    voltage = monsoon_results[0].voltage
69
70    total_current = 0
71    total_samples = 0
72    for result in monsoon_results:
73        total_current += result.average_current * result.num_samples
74        total_samples += result.num_samples
75    avg_current = total_current / total_samples
76
77    time_relative = [
78        data_point.time
79        for monsoon_result in monsoon_results
80        for data_point in monsoon_result.get_data_points()
81    ]
82
83    current_data = [
84        data_point.current * 1000
85        for monsoon_result in monsoon_results
86        for data_point in monsoon_result.get_data_points()
87    ]
88
89    total_data_points = sum(result.num_samples for result in monsoon_results)
90    color = ['navy'] * total_data_points
91
92    # Preparing the data and source link for bokehn java callback
93    source = ColumnDataSource(
94        data=dict(x0=time_relative, y0=current_data, color=color))
95    s2 = ColumnDataSource(
96        data=dict(
97            z0=[mon_info.duration],
98            y0=[round(avg_current, 2)],
99            x0=[round(avg_current * voltage, 2)],
100            z1=[round(avg_current * voltage * mon_info.duration, 2)],
101            z2=[round(avg_current * mon_info.duration, 2)]))
102    # Setting up data table for the output
103    columns = [
104        TableColumn(field='z0', title='Total Duration (s)'),
105        TableColumn(field='y0', title='Average Current (mA)'),
106        TableColumn(field='x0', title='Average Power (4.2v) (mW)'),
107        TableColumn(field='z1', title='Average Energy (mW*s)'),
108        TableColumn(field='z2', title='Normalized Average Energy (mA*s)')
109    ]
110    dt = DataTable(
111        source=s2, columns=columns, width=1300, height=60, editable=True)
112
113    plot_title = (os.path.basename(os.path.splitext(monsoon_results[0].tag)[0])
114                  + tag)
115    output_file(os.path.join(mon_info.data_path, plot_title + '.html'))
116    tools = 'box_zoom,box_select,pan,crosshair,redo,undo,reset,hover,save'
117    # Create a new plot with the datatable above
118    plot = figure(
119        plot_width=1300,
120        plot_height=700,
121        title=plot_title,
122        tools=tools,
123        output_backend='webgl')
124    plot.add_tools(bokeh_tools.WheelZoomTool(dimensions='width'))
125    plot.add_tools(bokeh_tools.WheelZoomTool(dimensions='height'))
126    plot.line('x0', 'y0', source=source, line_width=2)
127    plot.circle('x0', 'y0', source=source, size=0.5, fill_color='color')
128    plot.xaxis.axis_label = 'Time (s)'
129    plot.yaxis.axis_label = 'Current (mA)'
130    plot.title.text_font_size = {'value': '15pt'}
131
132    # Callback JavaScript
133    source.selected.js_on_change(
134        "indices",
135        CustomJS(args=dict(source=source, mytable=dt), code="""
136    var inds = cb_obj.indices;
137    var d1 = source.data;
138    var d2 = mytable.source.data;
139    ym = 0
140    ts = 0
141    d2['x0'] = []
142    d2['y0'] = []
143    d2['z1'] = []
144    d2['z2'] = []
145    d2['z0'] = []
146    min=max=d1['x0'][inds[0]]
147    if (inds.length==0) {return;}
148    for (i = 0; i < inds.length; i++) {
149    ym += d1['y0'][inds[i]]
150    d1['color'][inds[i]] = "red"
151    if (d1['x0'][inds[i]] < min) {
152      min = d1['x0'][inds[i]]}
153    if (d1['x0'][inds[i]] > max) {
154      max = d1['x0'][inds[i]]}
155    }
156    ym /= inds.length
157    ts = max - min
158    dx0 = Math.round(ym*4.2*100.0)/100.0
159    dy0 = Math.round(ym*100.0)/100.0
160    dz1 = Math.round(ym*4.2*ts*100.0)/100.0
161    dz2 = Math.round(ym*ts*100.0)/100.0
162    dz0 = Math.round(ts*1000.0)/1000.0
163    d2['z0'].push(dz0)
164    d2['x0'].push(dx0)
165    d2['y0'].push(dy0)
166    d2['z1'].push(dz1)
167    d2['z2'].push(dz2)
168    mytable.change.emit();
169    """))
170
171    # Layout the plot and the datatable bar
172    save(layout([[dt], [plot]]))
173    return plot, dt
174
175
176def change_dtim(ad, gEnableModulatedDTIM, gMaxLIModulatedDTIM=10):
177    """Function to change the DTIM setting in the phone.
178
179    Args:
180        ad: the target android device, AndroidDevice object
181        gEnableModulatedDTIM: Modulated DTIM, int
182        gMaxLIModulatedDTIM: Maximum modulated DTIM, int
183    """
184    # First trying to find the ini file with DTIM settings
185    ini_file_phone = ad.adb.shell('ls /vendor/firmware/wlan/*/*.ini')
186    ini_file_local = ini_file_phone.split('/')[-1]
187
188    # Pull the file and change the DTIM to desired value
189    ad.adb.pull('{} {}'.format(ini_file_phone, ini_file_local))
190
191    with open(ini_file_local, 'r') as fin:
192        for line in fin:
193            if ENABLED_MODULATED_DTIM in line:
194                gE_old = line.strip('\n')
195                gEDTIM_old = line.strip(ENABLED_MODULATED_DTIM).strip('\n')
196            if MAX_MODULATED_DTIM in line:
197                gM_old = line.strip('\n')
198                gMDTIM_old = line.strip(MAX_MODULATED_DTIM).strip('\n')
199    fin.close()
200    if int(gEDTIM_old) == gEnableModulatedDTIM and int(
201            gMDTIM_old) == gMaxLIModulatedDTIM:
202        ad.log.info('Current DTIM is already the desired value,'
203                    'no need to reset it')
204        return 0
205
206    gE_new = ENABLED_MODULATED_DTIM + str(gEnableModulatedDTIM)
207    gM_new = MAX_MODULATED_DTIM + str(gMaxLIModulatedDTIM)
208
209    sed_gE = 'sed -i \'s/{}/{}/g\' {}'.format(gE_old, gE_new, ini_file_local)
210    sed_gM = 'sed -i \'s/{}/{}/g\' {}'.format(gM_old, gM_new, ini_file_local)
211    job.run(sed_gE)
212    job.run(sed_gM)
213
214    # Push the file to the phone
215    push_file_to_phone(ad, ini_file_local, ini_file_phone)
216    ad.log.info('DTIM changes checked in and rebooting...')
217    ad.reboot()
218    # Wait for auto-wifi feature to start
219    time.sleep(20)
220    ad.adb.shell('dumpsys battery set level 100')
221    ad.log.info('DTIM updated and device back from reboot')
222    return 1
223
224
225def push_file_to_phone(ad, file_local, file_phone):
226    """Function to push local file to android phone.
227
228    Args:
229        ad: the target android device
230        file_local: the locla file to push
231        file_phone: the file/directory on the phone to be pushed
232    """
233    ad.adb.root()
234    cmd_out = ad.adb.remount()
235    if 'Permission denied' in cmd_out:
236        ad.log.info('Need to disable verity first and reboot')
237        ad.adb.disable_verity()
238        time.sleep(1)
239        ad.reboot()
240        ad.log.info('Verity disabled and device back from reboot')
241        ad.adb.root()
242        ad.adb.remount()
243    time.sleep(1)
244    ad.adb.push('{} {}'.format(file_local, file_phone))
245
246
247def ap_setup(ap, network, bandwidth=80):
248    """Set up the whirlwind AP with provided network info.
249
250    Args:
251        ap: access_point object of the AP
252        network: dict with information of the network, including ssid, password
253                 bssid, channel etc.
254        bandwidth: the operation bandwidth for the AP, default 80MHz
255    Returns:
256        brconfigs: the bridge interface configs
257    """
258    log = logging.getLogger()
259    bss_settings = []
260    ssid = network[wutils.WifiEnums.SSID_KEY]
261    if "password" in network.keys():
262        password = network["password"]
263        security = hostapd_security.Security(
264            security_mode="wpa", password=password)
265    else:
266        security = hostapd_security.Security(security_mode=None, password=None)
267    channel = network["channel"]
268    config = hostapd_ap_preset.create_ap_preset(
269        channel=channel,
270        ssid=ssid,
271        security=security,
272        bss_settings=bss_settings,
273        vht_bandwidth=bandwidth,
274        profile_name='whirlwind',
275        iface_wlan_2g=ap.wlan_2g,
276        iface_wlan_5g=ap.wlan_5g)
277    config_bridge = ap.generate_bridge_configs(channel)
278    brconfigs = bi.BridgeInterfaceConfigs(config_bridge[0], config_bridge[1],
279                                          config_bridge[2])
280    ap.bridge.startup(brconfigs)
281    ap.start_ap(config)
282    log.info("AP started on channel {} with SSID {}".format(channel, ssid))
283    return brconfigs
284
285
286def bokeh_plot(data_sets,
287               legends,
288               fig_property,
289               shaded_region=None,
290               output_file_path=None):
291    """Plot bokeh figs.
292        Args:
293            data_sets: data sets including lists of x_data and lists of y_data
294                       ex: [[[x_data1], [x_data2]], [[y_data1],[y_data2]]]
295            legends: list of legend for each curve
296            fig_property: dict containing the plot property, including title,
297                      lables, linewidth, circle size, etc.
298            shaded_region: optional dict containing data for plot shading
299            output_file_path: optional path at which to save figure
300        Returns:
301            plot: bokeh plot figure object
302    """
303    tools = 'box_zoom,box_select,pan,crosshair,redo,undo,reset,hover,save'
304    plot = figure(
305        plot_width=1300,
306        plot_height=700,
307        title=fig_property['title'],
308        tools=tools,
309        output_backend="webgl")
310    plot.add_tools(bokeh_tools.WheelZoomTool(dimensions="width"))
311    plot.add_tools(bokeh_tools.WheelZoomTool(dimensions="height"))
312    colors = [
313        'red', 'green', 'blue', 'olive', 'orange', 'salmon', 'black', 'navy',
314        'yellow', 'darkred', 'goldenrod'
315    ]
316    if shaded_region:
317        band_x = shaded_region["x_vector"]
318        band_x.extend(shaded_region["x_vector"][::-1])
319        band_y = shaded_region["lower_limit"]
320        band_y.extend(shaded_region["upper_limit"][::-1])
321        plot.patch(
322            band_x, band_y, color='#7570B3', line_alpha=0.1, fill_alpha=0.1)
323
324    for x_data, y_data, legend in zip(data_sets[0], data_sets[1], legends):
325        index_now = legends.index(legend)
326        color = colors[index_now % len(colors)]
327        plot.line(
328            x_data,
329            y_data,
330            legend=str(legend),
331            line_width=fig_property['linewidth'],
332            color=color)
333        plot.circle(
334            x_data,
335            y_data,
336            size=fig_property['markersize'],
337            legend=str(legend),
338            fill_color=color)
339
340    # Plot properties
341    plot.xaxis.axis_label = fig_property['x_label']
342    plot.yaxis.axis_label = fig_property['y_label']
343    plot.legend.location = "top_right"
344    plot.legend.click_policy = "hide"
345    plot.title.text_font_size = {'value': '15pt'}
346    if output_file_path is not None:
347        output_file(output_file_path)
348        save(plot)
349    return plot
350
351
352def save_bokeh_plots(plot_array, output_file_path):
353    all_plots = column(children=plot_array)
354    output_file(output_file_path)
355    save(all_plots)
356
357
358def run_iperf_client_nonblocking(ad, server_host, extra_args=""):
359    """Start iperf client on the device with nohup.
360
361    Return status as true if iperf client start successfully.
362    And data flow information as results.
363
364    Args:
365        ad: the android device under test
366        server_host: Address of the iperf server.
367        extra_args: A string representing extra arguments for iperf client,
368            e.g. "-i 1 -t 30".
369
370    """
371    log = logging.getLogger()
372    ad.adb.shell_nb("nohup >/dev/null 2>&1 sh -c 'iperf3 -c {} {} &'".format(
373        server_host, extra_args))
374    log.info("IPerf client started")
375
376
377def get_wifi_rssi(ad):
378    """Get the RSSI of the device.
379
380    Args:
381        ad: the android device under test
382    Returns:
383        RSSI: the rssi level of the device
384    """
385    RSSI = ad.droid.wifiGetConnectionInfo()['rssi']
386    return RSSI
387
388
389def get_phone_ip(ad):
390    """Get the WiFi IP address of the phone.
391
392    Args:
393        ad: the android device under test
394    Returns:
395        IP: IP address of the phone for WiFi, as a string
396    """
397    IP = ad.droid.connectivityGetIPv4Addresses('wlan0')[0]
398
399    return IP
400
401
402def get_phone_mac(ad):
403    """Get the WiFi MAC address of the phone.
404
405    Args:
406        ad: the android device under test
407    Returns:
408        mac: MAC address of the phone for WiFi, as a string
409    """
410    mac = ad.droid.wifiGetConnectionInfo()["mac_address"]
411
412    return mac
413
414
415def get_phone_ipv6(ad):
416    """Get the WiFi IPV6 address of the phone.
417
418    Args:
419        ad: the android device under test
420    Returns:
421        IPv6: IPv6 address of the phone for WiFi, as a string
422    """
423    IPv6 = ad.droid.connectivityGetLinkLocalIpv6Address('wlan0')[:-6]
424
425    return IPv6
426
427
428def get_if_addr6(intf, address_type):
429    """Returns the Ipv6 address from a given local interface.
430
431    Returns the desired IPv6 address from the interface 'intf' in human
432    readable form. The address type is indicated by the IPv6 constants like
433    IPV6_ADDR_LINKLOCAL, IPV6_ADDR_GLOBAL, etc. If no address is found,
434    None is returned.
435
436    Args:
437        intf: desired interface name
438        address_type: addrees typle like LINKLOCAL or GLOBAL
439
440    Returns:
441        Ipv6 address of the specified interface in human readable format
442    """
443    for if_list in scapy.in6_getifaddr():
444        if if_list[2] == intf and if_list[1] == address_type:
445            return if_list[0]
446
447    return None
448
449
450def wait_for_dhcp(interface_name):
451    """Wait the DHCP address assigned to desired interface.
452
453    Getting DHCP address takes time and the wait time isn't constant. Utilizing
454    utils.timeout to keep trying until success
455
456    Args:
457        interface_name: desired interface name
458    Returns:
459        ip: ip address of the desired interface name
460    Raise:
461        TimeoutError: After timeout, if no DHCP assigned, raise
462    """
463    log = logging.getLogger()
464    reset_host_interface(interface_name)
465    start_time = time.time()
466    time_limit_seconds = 60
467    ip = '0.0.0.0'
468    while start_time + time_limit_seconds > time.time():
469        ip = scapy.get_if_addr(interface_name)
470        if ip == '0.0.0.0':
471            time.sleep(1)
472        else:
473            log.info(
474                'DHCP address assigned to %s as %s' % (interface_name, ip))
475            return ip
476    raise TimeoutError('Timed out while getting if_addr after %s seconds.' %
477                       time_limit_seconds)
478
479
480def reset_host_interface(intferface_name):
481    """Reset the host interface.
482
483    Args:
484        intferface_name: the desired interface to reset
485    """
486    log = logging.getLogger()
487    intf_down_cmd = 'ifconfig %s down' % intferface_name
488    intf_up_cmd = 'ifconfig %s up' % intferface_name
489    try:
490        job.run(intf_down_cmd)
491        time.sleep(10)
492        job.run(intf_up_cmd)
493        log.info('{} has been reset'.format(intferface_name))
494    except job.Error:
495        raise Exception('No such interface')
496
497
498def bringdown_host_interface(intferface_name):
499    """Reset the host interface.
500
501    Args:
502        intferface_name: the desired interface to reset
503    """
504    log = logging.getLogger()
505    intf_down_cmd = 'ifconfig %s down' % intferface_name
506    try:
507        job.run(intf_down_cmd)
508        time.sleep(2)
509        log.info('{} has been brought down'.format(intferface_name))
510    except job.Error:
511        raise Exception('No such interface')
512
513
514def create_pkt_config(test_class):
515    """Creates the config for generating multicast packets
516
517    Args:
518        test_class: object with all networking paramters
519
520    Returns:
521        Dictionary with the multicast packet config
522    """
523    addr_type = (scapy.IPV6_ADDR_LINKLOCAL
524                 if test_class.ipv6_src_type == 'LINK_LOCAL' else
525                 scapy.IPV6_ADDR_GLOBAL)
526
527    mac_dst = test_class.mac_dst
528    if GET_FROM_PHONE in test_class.mac_dst:
529        mac_dst = get_phone_mac(test_class.dut)
530
531    ipv4_dst = test_class.ipv4_dst
532    if GET_FROM_PHONE in test_class.ipv4_dst:
533        ipv4_dst = get_phone_ip(test_class.dut)
534
535    ipv6_dst = test_class.ipv6_dst
536    if GET_FROM_PHONE in test_class.ipv6_dst:
537        ipv6_dst = get_phone_ipv6(test_class.dut)
538
539    ipv4_gw = test_class.ipv4_gwt
540    if GET_FROM_AP in test_class.ipv4_gwt:
541        ipv4_gw = test_class.access_point.ssh_settings.hostname
542
543    pkt_gen_config = {
544        'interf': test_class.pkt_sender.interface,
545        'subnet_mask': test_class.sub_mask,
546        'src_mac': test_class.mac_src,
547        'dst_mac': mac_dst,
548        'src_ipv4': test_class.ipv4_src,
549        'dst_ipv4': ipv4_dst,
550        'src_ipv6': test_class.ipv6_src,
551        'src_ipv6_type': addr_type,
552        'dst_ipv6': ipv6_dst,
553        'gw_ipv4': ipv4_gw
554    }
555    return pkt_gen_config
556