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