1"""Module managing the required definitions for using the bits power monitor""" 2 3import logging 4import os 5import time 6import uuid 7 8from acts import context 9from acts.controllers import power_metrics 10from acts.controllers import power_monitor 11from acts.controllers.bits_lib import bits_client 12from acts.controllers.bits_lib import bits_service 13from acts.controllers.bits_lib import bits_service_config as bsc 14 15MOBLY_CONTROLLER_CONFIG_NAME = 'Bits' 16ACTS_CONTROLLER_REFERENCE_NAME = 'bitses' 17 18 19def create(configs): 20 return [Bits(index, config) for (index, config) in enumerate(configs)] 21 22 23def destroy(bitses): 24 for bits in bitses: 25 bits.teardown() 26 27 28def get_info(bitses): 29 return [bits.config for bits in bitses] 30 31 32class BitsError(Exception): 33 pass 34 35 36class _BitsCollection(object): 37 """Object that represents a bits collection 38 39 Attributes: 40 name: The name given to the collection. 41 markers_buffer: An array of un-flushed markers, each marker is 42 represented by a bi-dimensional tuple with the format 43 (<nanoseconds_since_epoch or datetime>, <text>). 44 monsoon_output_path: A path to store monsoon-like data if possible, Bits 45 uses this path to attempt data extraction in monsoon format, if this 46 parameter is left as None such extraction is not attempted. 47 """ 48 49 def __init__(self, name, monsoon_output_path=None): 50 self.monsoon_output_path = monsoon_output_path 51 self.name = name 52 self.markers_buffer = [] 53 54 def add_marker(self, timestamp, marker_text): 55 self.markers_buffer.append((timestamp, marker_text)) 56 57 58def _transform_name(bits_metric_name): 59 """Transform bits metrics names to a more succinct version. 60 61 Examples of bits_metrics_name as provided by the client: 62 - default_device.slider.C1_30__PP0750_L1S_VDD_G3D_M_P:mA, 63 - default_device.slider.C1_30__PP0750_L1S_VDD_G3D_M_P:mW, 64 - default_device.Monsoon.Monsoon:mA, 65 - default_device.Monsoon.Monsoon:mW, 66 - <device>.<collector>.<rail>:<unit> 67 68 Args: 69 bits_metric_name: A bits metric name. 70 71 Returns: 72 For monsoon metrics, and for backwards compatibility: 73 Monsoon:mA -> avg_current, 74 Monsoon:mW -> avg_power, 75 76 For everything else: 77 <rail>:mW -> <rail/rail>_avg_current 78 <rail>:mW -> <rail/rail>_avg_power 79 ... 80 """ 81 prefix, unit = bits_metric_name.split(':') 82 rail = prefix.split('.')[-1] 83 84 if 'mW' == unit: 85 suffix = 'avg_power' 86 elif 'mA' == unit: 87 suffix = 'avg_current' 88 elif 'mV' == unit: 89 suffix = 'avg_voltage' 90 else: 91 logging.warning('unknown unit type for unit %s' % unit) 92 suffix = '' 93 94 if 'Monsoon' == rail: 95 return suffix 96 elif suffix == '': 97 return rail 98 else: 99 return '%s_%s' % (rail, suffix) 100 101 102def _raw_data_to_metrics(raw_data_obj): 103 data = raw_data_obj['data'] 104 metrics = [] 105 for sample in data: 106 unit = sample['unit'] 107 if 'Msg' == unit: 108 continue 109 elif 'mW' == unit: 110 unit_type = 'power' 111 elif 'mA' == unit: 112 unit_type = 'current' 113 elif 'mV' == unit: 114 unit_type = 'voltage' 115 else: 116 logging.warning('unknown unit type for unit %s' % unit) 117 continue 118 119 name = _transform_name(sample['name']) 120 avg = sample['avg'] 121 metrics.append(power_metrics.Metric(avg, unit_type, unit, name=name)) 122 123 return metrics 124 125 126def _get_single_file(registry, key): 127 if key not in registry: 128 return None 129 entry = registry[key] 130 if isinstance(entry, str): 131 return entry 132 if isinstance(entry, list): 133 return None if len(entry) == 0 else entry[0] 134 raise ValueError('registry["%s"] is of unsupported type %s for this ' 135 'operation. Supported types are str and list.' % ( 136 key, type(entry))) 137 138 139class Bits(object): 140 141 ROOT_RAIL_KEY = 'RootRail' 142 ROOT_RAIL_DEFAULT_VALUE = 'Monsoon:mA' 143 144 def __init__(self, index, config): 145 """Creates an instance of a bits controller. 146 147 Args: 148 index: An integer identifier for this instance, this allows to 149 tell apart different instances in the case where multiple 150 bits controllers are being used concurrently. 151 config: The config as defined in the ACTS BiTS controller config. 152 Expected format is: 153 { 154 // optional 155 'Monsoon': { 156 'serial_num': <serial number:int>, 157 'monsoon_voltage': <voltage:double> 158 } 159 // optional 160 'Kibble': [ 161 { 162 'board': 'BoardName1', 163 'connector': 'A', 164 'serial': 'serial_1' 165 }, 166 { 167 'board': 'BoardName2', 168 'connector': 'D', 169 'serial': 'serial_2' 170 } 171 ] 172 // optional 173 'RootRail': 'Monsoon:mA' 174 } 175 """ 176 self.index = index 177 self.config = config 178 self._service = None 179 self._client = None 180 self._active_collection = None 181 self._collections_counter = 0 182 self._root_rail = config.get(self.ROOT_RAIL_KEY, 183 self.ROOT_RAIL_DEFAULT_VALUE) 184 185 def setup(self, *_, registry=None, **__): 186 """Starts a bits_service in the background. 187 188 This function needs to be called with either a registry or after calling 189 power_monitor.update_registry, and it needs to be called before any other 190 method in this class. 191 192 Args: 193 registry: A dictionary with files used by bits. Format: 194 { 195 // required, string or list of strings 196 bits_service: ['/path/to/bits_service'] 197 198 // required, string or list of strings 199 bits_client: ['/path/to/bits.par'] 200 201 // needed for monsoon, string or list of strings 202 lvpm_monsoon: ['/path/to/lvpm_monsoon.par'] 203 204 // needed for monsoon, string or list of strings 205 hvpm_monsoon: ['/path/to/hvpm_monsoon.par'] 206 207 // needed for kibble, string or list of strings 208 kibble_bin: ['/path/to/kibble.par'] 209 210 // needed for kibble, string or list of strings 211 kibble_board_file: ['/path/to/phone_s.board'] 212 213 // optional, string or list of strings 214 vm_file: ['/path/to/file.vm'] 215 } 216 217 All fields in this dictionary can be either a string or a list 218 of strings. If lists are passed, only their first element is 219 taken into account. The reason for supporting lists but only 220 acting on their first element is for easier integration with 221 harnesses that handle resources as lists. 222 """ 223 if registry is None: 224 registry = power_monitor.get_registry() 225 if 'bits_service' not in registry: 226 raise ValueError('No bits_service binary has been defined in the ' 227 'global registry.') 228 if 'bits_client' not in registry: 229 raise ValueError('No bits_client binary has been defined in the ' 230 'global registry.') 231 232 bits_service_binary = _get_single_file(registry, 'bits_service') 233 bits_client_binary = _get_single_file(registry, 'bits_client') 234 lvpm_monsoon_bin = _get_single_file(registry, 'lvpm_monsoon') 235 hvpm_monsoon_bin = _get_single_file(registry, 'hvpm_monsoon') 236 kibble_bin = _get_single_file(registry, 'kibble_bin') 237 kibble_board_file = _get_single_file(registry, 'kibble_board_file') 238 vm_file = _get_single_file(registry, 'vm_file') 239 config = bsc.BitsServiceConfig(self.config, 240 lvpm_monsoon_bin=lvpm_monsoon_bin, 241 hvpm_monsoon_bin=hvpm_monsoon_bin, 242 kibble_bin=kibble_bin, 243 kibble_board_file=kibble_board_file, 244 virtual_metrics_file=vm_file) 245 output_log = os.path.join( 246 context.get_current_context().get_full_output_path(), 247 'bits_service_out_%s.txt' % self.index) 248 service_name = 'bits_config_%s' % self.index 249 250 self._active_collection = None 251 self._collections_counter = 0 252 self._service = bits_service.BitsService(config, 253 bits_service_binary, 254 output_log, 255 name=service_name, 256 timeout=3600 * 24) 257 self._service.start() 258 self._client = bits_client.BitsClient(bits_client_binary, 259 self._service, 260 config) 261 # this call makes sure that the client can interact with the server. 262 devices = self._client.list_devices() 263 logging.debug(devices) 264 265 def disconnect_usb(self, *_, **__): 266 self._client.disconnect_usb() 267 268 def connect_usb(self, *_, **__): 269 self._client.connect_usb() 270 271 def measure(self, *_, measurement_args=None, 272 measurement_name=None, monsoon_output_path=None, 273 **__): 274 """Blocking function that measures power through bits for the specified 275 duration. Results need to be consulted through other methods such as 276 get_metrics or post processing files like the ones 277 generated at monsoon_output_path after calling `release_resources`. 278 279 Args: 280 measurement_args: A dictionary with the following structure: 281 { 282 'duration': <seconds to measure for> 283 'hz': <samples per second> 284 'measure_after_seconds': <sleep time before measurement> 285 } 286 The actual number of samples per second is limited by the 287 bits configuration. The value of hz is defaulted to 1000. 288 measurement_name: A name to give to the measurement (which is also 289 used as the Bits collection name. Bits collection names (and 290 therefore measurement names) need to be unique within the 291 context of a Bits object. 292 monsoon_output_path: If provided this path will be used to generate 293 a monsoon like formatted file at the release_resources step. 294 """ 295 if measurement_args is None: 296 raise ValueError('measurement_args can not be left undefined') 297 298 duration = measurement_args.get('duration') 299 if duration is None: 300 raise ValueError( 301 'duration can not be left undefined within measurement_args') 302 303 hz = measurement_args.get('hz', 1000) 304 305 # Delay the start of the measurement if an offset is required 306 measure_after_seconds = measurement_args.get('measure_after_seconds') 307 if measure_after_seconds: 308 time.sleep(measure_after_seconds) 309 310 if self._active_collection: 311 raise BitsError( 312 'Attempted to start a collection while there is still an ' 313 'active one. Active collection: %s', 314 self._active_collection.name) 315 316 self._collections_counter = self._collections_counter + 1 317 # The name gets a random 8 characters salt suffix because the Bits 318 # client has a bug where files with the same name are considered to be 319 # the same collection and it won't load two files with the same name. 320 # b/153170987 b/153944171 321 if not measurement_name: 322 measurement_name = 'bits_collection_%s_%s' % ( 323 str(self._collections_counter), str(uuid.uuid4())[0:8]) 324 325 self._active_collection = _BitsCollection(measurement_name, 326 monsoon_output_path) 327 self._client.start_collection(self._active_collection.name, 328 default_sampling_rate=hz) 329 time.sleep(duration) 330 331 def get_metrics(self, *_, timestamps=None, **__): 332 """Gets metrics for the segments delimited by the timestamps dictionary. 333 334 Must be called before releasing resources, otherwise it will fail adding 335 markers to the collection. 336 337 Args: 338 timestamps: A dictionary of the shape: 339 { 340 'segment_name': { 341 'start' : <milliseconds_since_epoch> or <datetime> 342 'end': <milliseconds_since_epoch> or <datetime> 343 } 344 'another_segment': { 345 'start' : <milliseconds_since_epoch> or <datetime> 346 'end': <milliseconds_since_epoch> or <datetime> 347 } 348 } 349 Returns: 350 A dictionary of the shape: 351 { 352 'segment_name': <list of power_metrics.Metric> 353 'another_segment': <list of power_metrics.Metric> 354 } 355 """ 356 if timestamps is None: 357 raise ValueError('timestamps dictionary can not be left undefined') 358 359 metrics = {} 360 361 for segment_name, times in timestamps.items(): 362 if 'start' not in times or 'end' not in times: 363 continue 364 365 start = times['start'] 366 end = times['end'] 367 368 # bits accepts nanoseconds only, but since this interface needs to 369 # backwards compatible with monsoon which works with milliseconds we 370 # require to do a conversion from milliseconds to nanoseconds. 371 # The preferred way for new calls to this function should be using 372 # datetime instead which is unambiguous 373 if isinstance(start, (int, float)): 374 start = start * 1e6 375 if isinstance(end, (int, float)): 376 end = end * 1e6 377 378 raw_metrics = self._client.get_metrics(self._active_collection.name, 379 start=start, end=end) 380 self._add_marker(start, 'start - %s' % segment_name) 381 self._add_marker(end, 'end - %s' % segment_name) 382 metrics[segment_name] = _raw_data_to_metrics(raw_metrics) 383 return metrics 384 385 def _add_marker(self, timestamp, marker_text): 386 if not self._active_collection: 387 raise BitsError( 388 'markers can not be added without an active collection') 389 self._active_collection.add_marker(timestamp, marker_text) 390 391 def release_resources(self): 392 """Performs all the cleanup and export tasks. 393 394 In the way that Bits' is interfaced several tasks can not be performed 395 while a collection is still active (like exporting the data) and others 396 can only take place while the collection is still active (like adding 397 markers to a collection). 398 399 To workaround this unique workflow, the collections that are started 400 with the 'measure' method are not really stopped after the method 401 is unblocked, it is only stopped after this method is called. 402 403 All the export files (.7z.bits and monsoon-formatted file) are also 404 generated in this method. 405 """ 406 if not self._active_collection: 407 raise BitsError( 408 'Attempted to stop a collection without starting one') 409 self._client.add_markers(self._active_collection.name, 410 self._active_collection.markers_buffer) 411 self._client.stop_collection(self._active_collection.name) 412 413 export_file = os.path.join( 414 context.get_current_context().get_full_output_path(), 415 '%s.7z.bits' % self._active_collection.name) 416 self._client.export(self._active_collection.name, export_file) 417 if self._active_collection.monsoon_output_path: 418 self._attempt_monsoon_format() 419 self._active_collection = None 420 421 def _attempt_monsoon_format(self): 422 """Attempts to create a monsoon-formatted file. 423 424 In the case where there is not enough information to retrieve a 425 monsoon-like file, this function will do nothing. 426 """ 427 available_channels = self._client.list_channels( 428 self._active_collection.name) 429 milli_amps_channel = None 430 431 for channel in available_channels: 432 if channel.endswith(self._root_rail): 433 milli_amps_channel = self._root_rail 434 break 435 436 if milli_amps_channel is None: 437 logging.debug('No monsoon equivalent channels were found when ' 438 'attempting to recreate monsoon file format. ' 439 'Available channels were: %s', 440 str(available_channels)) 441 return 442 443 logging.debug('Recreating monsoon file format from channel: %s', 444 milli_amps_channel) 445 self._client.export_as_monsoon_format( 446 self._active_collection.monsoon_output_path, 447 self._active_collection.name, 448 milli_amps_channel) 449 450 def get_waveform(self, file_path=None): 451 """Parses a file generated in release_resources. 452 453 Args: 454 file_path: Path to a waveform file. 455 456 Returns: 457 A list of tuples in which the first element is a timestamp and the 458 second element is the sampled current at that time. 459 """ 460 if file_path is None: 461 raise ValueError('file_path can not be None') 462 463 return list(power_metrics.import_raw_data(file_path)) 464 465 def teardown(self): 466 if self._service is None: 467 return 468 469 if self._service.service_state == bits_service.BitsServiceStates.STARTED: 470 self._service.stop() 471