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 csv 18from datetime import datetime 19import logging 20import tempfile 21 22from acts.libs.proc import job 23import yaml 24 25 26class BitsClientError(Exception): 27 pass 28 29 30# An arbitrary large number of seconds. 31ONE_YEAR = str(3600 * 24 * 365) 32EPOCH = datetime.utcfromtimestamp(0) 33 34 35def _to_ns(timestamp): 36 """Returns the numerical value of a timestamp in nanoseconds since epoch. 37 38 Args: 39 timestamp: Either a number or a datetime. 40 41 Returns: 42 Rounded timestamp if timestamp is numeric, number of nanoseconds since 43 epoch if timestamp is instance of datetime.datetime. 44 """ 45 if isinstance(timestamp, datetime): 46 return int((timestamp - EPOCH).total_seconds() * 1e9) 47 elif isinstance(timestamp, (float, int)): 48 return int(timestamp) 49 raise ValueError('%s can not be converted to a numerical representation of ' 50 'nanoseconds.' % type(timestamp)) 51 52 53class BitsClient(object): 54 """Helper class to issue bits' commands""" 55 56 def __init__(self, binary, service, service_config): 57 """Constructs a BitsClient. 58 59 Args: 60 binary: The location of the bits.par client binary. 61 service: A bits_service.BitsService object. The service is expected 62 to be previously setup. 63 service_config: The bits_service_config.BitsService object used to 64 start the service on service_port. 65 """ 66 self._log = logging.getLogger() 67 self._binary = binary 68 self._service = service 69 self._server_config = service_config 70 71 def _acquire_monsoon(self): 72 """Gets hold of a Monsoon so no other processes can use it. 73 Only works if there is a monsoon.""" 74 self._log.debug('acquiring monsoon') 75 self.run_cmd('--collector', 76 'Monsoon', 77 '--collector_cmd', 78 'acquire_monsoon', timeout=10) 79 80 def _release_monsoon(self): 81 self._log.debug('releasing monsoon') 82 self.run_cmd('--collector', 83 'Monsoon', 84 '--collector_cmd', 85 'release_monsoon', timeout=10) 86 87 def run_cmd(self, *args, timeout=60): 88 """Executes a generic bits.par command. 89 90 Args: 91 args: A bits.par command as a tokenized array. The path to the 92 binary and the service port are provided by default, cmd should 93 only contain the remaining tokens of the desired command. 94 timeout: Number of seconds to wait for the command to finish before 95 forcibly killing it. 96 """ 97 result = job.run([self._binary, '--port', 98 self._service.port] + [str(arg) for arg in args], 99 timeout=timeout) 100 return result.stdout 101 102 def export(self, collection_name, path): 103 """Exports a collection to its bits persistent format. 104 105 Exported files can be shared and opened through the Bits UI. 106 107 Args: 108 collection_name: Collection to be exported. 109 path: Where the resulting file should be created. Bits requires that 110 the resulting file ends in .7z.bits. 111 """ 112 if not path.endswith('.7z.bits'): 113 raise BitsClientError('Bits\' collections can only be exported to ' 114 'files ending in .7z.bits, got %s' % path) 115 self._log.debug('exporting collection %s to %s', 116 collection_name, 117 path) 118 self.run_cmd('--name', 119 collection_name, 120 '--ignore_gaps', 121 '--export', 122 '--export_path', 123 path, 124 timeout=600) 125 126 def export_as_csv(self, channels, collection_name, output_file): 127 """Export bits data as CSV. 128 129 Writes the selected channel data to the given output_file. Note that 130 the first line of the file contains headers. 131 132 Args: 133 channels: A list of string pattern matches for the channel to be 134 retrieved. For example, ":mW" will export all power channels, 135 ":mV" will export all voltage channels, "C1_01__" will export 136 power/voltage/current for the first fail of connector 1. 137 collection_name: A string for a collection that is sampling. 138 output_file: A string file path where the CSV will be written. 139 """ 140 channels_arg = ','.join(channels) 141 cmd = ['--csvfile', 142 output_file, 143 '--name', 144 collection_name, 145 '--ignore_gaps', 146 '--csv_rawtimestamps', 147 '--channels', 148 channels_arg] 149 if self._server_config.has_virtual_metrics_file: 150 cmd = cmd + ['--vm_file', 'default'] 151 self._log.debug( 152 'exporting csv for collection %s to %s, with channels %s', 153 collection_name, output_file, channels_arg) 154 self.run_cmd(*cmd, timeout=600) 155 156 def add_markers(self, collection_name, markers): 157 """Appends markers to a collection. 158 159 These markers are displayed in the Bits UI and are useful to label 160 important test events. 161 162 Markers can only be added to collections that have not been 163 closed / stopped. Markers need to be added in chronological order, 164 this function ensures that at least the markers added in each 165 call are sorted in chronological order, but if this function 166 is called multiple times, then is up to the user to ensure that 167 the subsequent batches of markers are for timestamps higher (newer) 168 than all the markers passed in previous calls to this function. 169 170 Args: 171 collection_name: The name of the collection to add markers to. 172 markers: A list of tuples of the shape: 173 174 [(<nano_seconds_since_epoch or datetime>, <marker text>), 175 (<nano_seconds_since_epoch or datetime>, <marker text>), 176 (<nano_seconds_since_epoch or datetime>, <marker text>), 177 ... 178 ] 179 """ 180 # sorts markers in chronological order before adding them. This is 181 # required by go/pixel-bits 182 for ts, marker in sorted(markers, key=lambda x: _to_ns(x[0])): 183 self._log.debug('Adding marker at %s: %s', str(ts), marker) 184 self.run_cmd('--name', 185 collection_name, 186 '--log_ts', 187 str(_to_ns(ts)), 188 '--log', 189 marker, 190 timeout=10) 191 192 def get_metrics(self, collection_name, start=None, end=None): 193 """Extracts metrics for a period of time. 194 195 Args: 196 collection_name: The name of the collection to get metrics from 197 start: Numerical nanoseconds since epoch until the start of the 198 period of interest or datetime. If not provided, start will be the 199 beginning of the collection. 200 end: Numerical nanoseconds since epoch until the end of the 201 period of interest or datetime. If not provided, end will be the 202 end of the collection. 203 """ 204 with tempfile.NamedTemporaryFile(prefix='bits_metrics') as tf: 205 cmd = ['--name', 206 collection_name, 207 '--ignore_gaps', 208 '--aggregates_yaml_path', 209 tf.name] 210 211 if start is not None: 212 cmd = cmd + ['--abs_start_time', str(_to_ns(start))] 213 if end is not None: 214 cmd = cmd + ['--abs_stop_time', str(_to_ns(end))] 215 if self._server_config.has_virtual_metrics_file: 216 cmd = cmd + ['--vm_file', 'default'] 217 218 self.run_cmd(*cmd) 219 with open(tf.name) as mf: 220 self._log.debug( 221 'bits aggregates for collection %s [%s-%s]: %s' % ( 222 collection_name, start, end, 223 mf.read())) 224 225 with open(tf.name) as mf: 226 return yaml.safe_load(mf) 227 228 def disconnect_usb(self): 229 """Disconnects the monsoon's usb. Only works if there is a monsoon""" 230 self._log.debug('disconnecting monsoon\'s usb') 231 self.run_cmd('--collector', 232 'Monsoon', 233 '--collector_cmd', 234 'usb_disconnect', timeout=10) 235 236 def start_collection(self, collection_name, default_sampling_rate=1000): 237 """Indicates Bits to start a collection. 238 239 Args: 240 collection_name: Name to give to the collection to be started. 241 Collection names must be unique at Bits' service level. If multiple 242 collections must be taken within the context of the same Bits' 243 service, ensure that each collection is given a different one. 244 default_sampling_rate: Samples per second to be collected 245 """ 246 247 cmd = ['--name', 248 collection_name, 249 '--non_blocking', 250 '--time', 251 ONE_YEAR, 252 '--default_sampling_rate', 253 str(default_sampling_rate)] 254 255 if self._server_config.has_kibbles: 256 cmd = cmd + ['--disk_space_saver'] 257 258 self._log.debug('starting collection %s', collection_name) 259 self.run_cmd(*cmd, timeout=10) 260 261 def connect_usb(self): 262 """Connects the monsoon's usb. Only works if there is a monsoon.""" 263 cmd = ['--collector', 264 'Monsoon', 265 '--collector_cmd', 266 'usb_connect'] 267 self._log.debug('connecting monsoon\'s usb') 268 self.run_cmd(*cmd, timeout=10) 269 270 def stop_collection(self, collection_name): 271 """Stops the active collection.""" 272 self._log.debug('stopping collection %s', collection_name) 273 self.run_cmd('--name', 274 collection_name, 275 '--stop') 276 self._log.debug('stopped collection %s', collection_name) 277 278 def list_devices(self): 279 """Lists devices managed by the bits_server this client is connected 280 to. 281 282 Returns: 283 bits' output when called with --list devices. 284 """ 285 self._log.debug('listing devices') 286 result = self.run_cmd('--list', 'devices', timeout=20) 287 return result 288 289 def list_channels(self, collection_name): 290 """Finds all the available channels in a given collection. 291 292 Args: 293 collection_name: The name of the collection to get channels from. 294 """ 295 metrics = self.get_metrics(collection_name) 296 return [channel['name'] for channel in metrics['data']] 297 298 def export_as_monsoon_format(self, dest_path, collection_name, 299 channel_pattern): 300 """Exports data from a collection in monsoon style. 301 302 This function exists because there are tools that have been built on 303 top of the monsoon format. To be able to leverage such tools we need 304 to make the data compliant with the format. 305 306 The monsoon format is: 307 308 <time_since_epoch_in_secs> <amps> 309 310 Args: 311 dest_path: Path where the resulting file will be generated. 312 collection_name: The name of the Bits' collection to export data 313 from. 314 channel_pattern: A regex that matches the Bits' channel to be used 315 as source of data. If there are multiple matching channels, only the 316 first one will be used. The channel is always assumed to be 317 expressed en milli-amps, the resulting format requires amps, so the 318 values coming from the first matching channel will always be 319 multiplied by 1000. 320 """ 321 with tempfile.NamedTemporaryFile(prefix='bits_csv_') as tmon: 322 self.export_as_csv([channel_pattern], collection_name, tmon.name) 323 324 self._log.debug( 325 'massaging bits csv to monsoon format for collection' 326 ' %s', collection_name) 327 with open(tmon.name) as csv_file: 328 reader = csv.reader(csv_file) 329 headers = next(reader) 330 self._log.debug('csv headers %s', headers) 331 with open(dest_path, 'w') as dest: 332 for row in reader: 333 ts = float(row[0]) / 1e9 334 amps = float(row[1]) / 1e3 335 dest.write('%.7f %.12f\n' % (ts, amps)) 336