1# Copyright 2015 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Provides a variety of device interactions with power.
6"""
7# pylint: disable=unused-argument
8
9import collections
10import contextlib
11import csv
12import logging
13
14from devil.android import decorators
15from devil.android import device_errors
16from devil.android import device_utils
17from devil.android.sdk import version_codes
18from devil.utils import timeout_retry
19
20_DEFAULT_TIMEOUT = 30
21_DEFAULT_RETRIES = 3
22
23
24_DEVICE_PROFILES = [
25  {
26    'name': 'Nexus 4',
27    'witness_file': '/sys/module/pm8921_charger/parameters/disabled',
28    'enable_command': (
29        'echo 0 > /sys/module/pm8921_charger/parameters/disabled && '
30        'dumpsys battery reset'),
31    'disable_command': (
32        'echo 1 > /sys/module/pm8921_charger/parameters/disabled && '
33        'dumpsys battery set ac 0 && dumpsys battery set usb 0'),
34    'charge_counter': None,
35    'voltage': None,
36    'current': None,
37  },
38  {
39    'name': 'Nexus 5',
40    # Nexus 5
41    # Setting the HIZ bit of the bq24192 causes the charger to actually ignore
42    # energy coming from USB. Setting the power_supply offline just updates the
43    # Android system to reflect that.
44    'witness_file': '/sys/kernel/debug/bq24192/INPUT_SRC_CONT',
45    'enable_command': (
46        'echo 0x4A > /sys/kernel/debug/bq24192/INPUT_SRC_CONT && '
47        'chmod 644 /sys/class/power_supply/usb/online && '
48        'echo 1 > /sys/class/power_supply/usb/online && '
49        'dumpsys battery reset'),
50    'disable_command': (
51        'echo 0xCA > /sys/kernel/debug/bq24192/INPUT_SRC_CONT && '
52        'chmod 644 /sys/class/power_supply/usb/online && '
53        'echo 0 > /sys/class/power_supply/usb/online && '
54        'dumpsys battery set ac 0 && dumpsys battery set usb 0'),
55    'charge_counter': None,
56    'voltage': None,
57    'current': None,
58  },
59  {
60    'name': 'Nexus 6',
61    'witness_file': None,
62    'enable_command': (
63        'echo 1 > /sys/class/power_supply/battery/charging_enabled && '
64        'dumpsys battery reset'),
65    'disable_command': (
66        'echo 0 > /sys/class/power_supply/battery/charging_enabled && '
67        'dumpsys battery set ac 0 && dumpsys battery set usb 0'),
68    'charge_counter': (
69        '/sys/class/power_supply/max170xx_battery/charge_counter_ext'),
70    'voltage': '/sys/class/power_supply/max170xx_battery/voltage_now',
71    'current': '/sys/class/power_supply/max170xx_battery/current_now',
72  },
73  {
74    'name': 'Nexus 9',
75    'witness_file': None,
76    'enable_command': (
77        'echo Disconnected > '
78        '/sys/bus/i2c/drivers/bq2419x/0-006b/input_cable_state && '
79        'dumpsys battery reset'),
80    'disable_command': (
81        'echo Connected > '
82        '/sys/bus/i2c/drivers/bq2419x/0-006b/input_cable_state && '
83        'dumpsys battery set ac 0 && dumpsys battery set usb 0'),
84    'charge_counter': '/sys/class/power_supply/battery/charge_counter_ext',
85    'voltage': '/sys/class/power_supply/battery/voltage_now',
86    'current': '/sys/class/power_supply/battery/current_now',
87  },
88  {
89    'name': 'Nexus 10',
90    'witness_file': None,
91    'enable_command': None,
92    'disable_command': None,
93    'charge_counter': None,
94    'voltage': '/sys/class/power_supply/ds2784-fuelgauge/voltage_now',
95    'current': '/sys/class/power_supply/ds2784-fuelgauge/current_now',
96
97  },
98]
99
100# The list of useful dumpsys columns.
101# Index of the column containing the format version.
102_DUMP_VERSION_INDEX = 0
103# Index of the column containing the type of the row.
104_ROW_TYPE_INDEX = 3
105# Index of the column containing the uid.
106_PACKAGE_UID_INDEX = 4
107# Index of the column containing the application package.
108_PACKAGE_NAME_INDEX = 5
109# The column containing the uid of the power data.
110_PWI_UID_INDEX = 1
111# The column containing the type of consumption. Only consumption since last
112# charge are of interest here.
113_PWI_AGGREGATION_INDEX = 2
114_PWS_AGGREGATION_INDEX = _PWI_AGGREGATION_INDEX
115# The column containing the amount of power used, in mah.
116_PWI_POWER_CONSUMPTION_INDEX = 5
117_PWS_POWER_CONSUMPTION_INDEX = _PWI_POWER_CONSUMPTION_INDEX
118
119_MAX_CHARGE_ERROR = 20
120
121
122class BatteryUtils(object):
123
124  def __init__(self, device, default_timeout=_DEFAULT_TIMEOUT,
125               default_retries=_DEFAULT_RETRIES):
126    """BatteryUtils constructor.
127
128      Args:
129        device: A DeviceUtils instance.
130        default_timeout: An integer containing the default number of seconds to
131                         wait for an operation to complete if no explicit value
132                         is provided.
133        default_retries: An integer containing the default number or times an
134                         operation should be retried on failure if no explicit
135                         value is provided.
136      Raises:
137        TypeError: If it is not passed a DeviceUtils instance.
138    """
139    if not isinstance(device, device_utils.DeviceUtils):
140      raise TypeError('Must be initialized with DeviceUtils object.')
141    self._device = device
142    self._cache = device.GetClientCache(self.__class__.__name__)
143    self._default_timeout = default_timeout
144    self._default_retries = default_retries
145
146  @decorators.WithTimeoutAndRetriesFromInstance()
147  def SupportsFuelGauge(self, timeout=None, retries=None):
148    """Detect if fuel gauge chip is present.
149
150    Args:
151      timeout: timeout in seconds
152      retries: number of retries
153
154    Returns:
155      True if known fuel gauge files are present.
156      False otherwise.
157    """
158    self._DiscoverDeviceProfile()
159    return (self._cache['profile']['enable_command'] != None
160        and self._cache['profile']['charge_counter'] != None)
161
162  @decorators.WithTimeoutAndRetriesFromInstance()
163  def GetFuelGaugeChargeCounter(self, timeout=None, retries=None):
164    """Get value of charge_counter on fuel gauge chip.
165
166    Device must have charging disabled for this, not just battery updates
167    disabled. The only device that this currently works with is the nexus 5.
168
169    Args:
170      timeout: timeout in seconds
171      retries: number of retries
172
173    Returns:
174      value of charge_counter for fuel gauge chip in units of nAh.
175
176    Raises:
177      device_errors.CommandFailedError: If fuel gauge chip not found.
178    """
179    if self.SupportsFuelGauge():
180      return int(self._device.ReadFile(
181          self._cache['profile']['charge_counter']))
182    raise device_errors.CommandFailedError(
183        'Unable to find fuel gauge.')
184
185  @decorators.WithTimeoutAndRetriesFromInstance()
186  def GetNetworkData(self, package, timeout=None, retries=None):
187    """Get network data for specific package.
188
189    Args:
190      package: package name you want network data for.
191      timeout: timeout in seconds
192      retries: number of retries
193
194    Returns:
195      Tuple of (sent_data, recieved_data)
196      None if no network data found
197    """
198    # If device_utils clears cache, cache['uids'] doesn't exist
199    if 'uids' not in self._cache:
200      self._cache['uids'] = {}
201    if package not in self._cache['uids']:
202      self.GetPowerData()
203      if package not in self._cache['uids']:
204        logging.warning('No UID found for %s. Can\'t get network data.',
205                        package)
206        return None
207
208    network_data_path = '/proc/uid_stat/%s/' % self._cache['uids'][package]
209    try:
210      send_data = int(self._device.ReadFile(network_data_path + 'tcp_snd'))
211    # If ReadFile throws exception, it means no network data usage file for
212    # package has been recorded. Return 0 sent and 0 received.
213    except device_errors.AdbShellCommandFailedError:
214      logging.warning('No sent data found for package %s', package)
215      send_data = 0
216    try:
217      recv_data = int(self._device.ReadFile(network_data_path + 'tcp_rcv'))
218    except device_errors.AdbShellCommandFailedError:
219      logging.warning('No received data found for package %s', package)
220      recv_data = 0
221    return (send_data, recv_data)
222
223  @decorators.WithTimeoutAndRetriesFromInstance()
224  def GetPowerData(self, timeout=None, retries=None):
225    """Get power data for device.
226
227    Args:
228      timeout: timeout in seconds
229      retries: number of retries
230
231    Returns:
232      Dict containing system power, and a per-package power dict keyed on
233      package names.
234      {
235        'system_total': 23.1,
236        'per_package' : {
237          package_name: {
238            'uid': uid,
239            'data': [1,2,3]
240          },
241        }
242      }
243    """
244    if 'uids' not in self._cache:
245      self._cache['uids'] = {}
246    dumpsys_output = self._device.RunShellCommand(
247        ['dumpsys', 'batterystats', '-c'],
248        check_return=True, large_output=True)
249    csvreader = csv.reader(dumpsys_output)
250    pwi_entries = collections.defaultdict(list)
251    system_total = None
252    for entry in csvreader:
253      if entry[_DUMP_VERSION_INDEX] not in ['8', '9']:
254        # Wrong dumpsys version.
255        raise device_errors.DeviceVersionError(
256            'Dumpsys version must be 8 or 9. %s found.'
257            % entry[_DUMP_VERSION_INDEX])
258      if _ROW_TYPE_INDEX < len(entry) and entry[_ROW_TYPE_INDEX] == 'uid':
259        current_package = entry[_PACKAGE_NAME_INDEX]
260        if (self._cache['uids'].get(current_package)
261            and self._cache['uids'].get(current_package)
262            != entry[_PACKAGE_UID_INDEX]):
263          raise device_errors.CommandFailedError(
264              'Package %s found multiple times with different UIDs %s and %s'
265               % (current_package, self._cache['uids'][current_package],
266               entry[_PACKAGE_UID_INDEX]))
267        self._cache['uids'][current_package] = entry[_PACKAGE_UID_INDEX]
268      elif (_PWI_POWER_CONSUMPTION_INDEX < len(entry)
269          and entry[_ROW_TYPE_INDEX] == 'pwi'
270          and entry[_PWI_AGGREGATION_INDEX] == 'l'):
271        pwi_entries[entry[_PWI_UID_INDEX]].append(
272            float(entry[_PWI_POWER_CONSUMPTION_INDEX]))
273      elif (_PWS_POWER_CONSUMPTION_INDEX < len(entry)
274          and entry[_ROW_TYPE_INDEX] == 'pws'
275          and entry[_PWS_AGGREGATION_INDEX] == 'l'):
276        # This entry should only appear once.
277        assert system_total is None
278        system_total = float(entry[_PWS_POWER_CONSUMPTION_INDEX])
279
280    per_package = {p: {'uid': uid, 'data': pwi_entries[uid]}
281                   for p, uid in self._cache['uids'].iteritems()}
282    return {'system_total': system_total, 'per_package': per_package}
283
284  @decorators.WithTimeoutAndRetriesFromInstance()
285  def GetBatteryInfo(self, timeout=None, retries=None):
286    """Gets battery info for the device.
287
288    Args:
289      timeout: timeout in seconds
290      retries: number of retries
291    Returns:
292      A dict containing various battery information as reported by dumpsys
293      battery.
294    """
295    result = {}
296    # Skip the first line, which is just a header.
297    for line in self._device.RunShellCommand(
298        ['dumpsys', 'battery'], check_return=True)[1:]:
299      # If usb charging has been disabled, an extra line of header exists.
300      if 'UPDATES STOPPED' in line:
301        logging.warning('Dumpsys battery not receiving updates. '
302                        'Run dumpsys battery reset if this is in error.')
303      elif ':' not in line:
304        logging.warning('Unknown line found in dumpsys battery: "%s"', line)
305      else:
306        k, v = line.split(':', 1)
307        result[k.strip()] = v.strip()
308    return result
309
310  @decorators.WithTimeoutAndRetriesFromInstance()
311  def GetCharging(self, timeout=None, retries=None):
312    """Gets the charging state of the device.
313
314    Args:
315      timeout: timeout in seconds
316      retries: number of retries
317    Returns:
318      True if the device is charging, false otherwise.
319    """
320    battery_info = self.GetBatteryInfo()
321    for k in ('AC powered', 'USB powered', 'Wireless powered'):
322      if (k in battery_info and
323          battery_info[k].lower() in ('true', '1', 'yes')):
324        return True
325    return False
326
327  # TODO(rnephew): Make private when all use cases can use the context manager.
328  @decorators.WithTimeoutAndRetriesFromInstance()
329  def DisableBatteryUpdates(self, timeout=None, retries=None):
330    """Resets battery data and makes device appear like it is not
331    charging so that it will collect power data since last charge.
332
333    Args:
334      timeout: timeout in seconds
335      retries: number of retries
336
337    Raises:
338      device_errors.CommandFailedError: When resetting batterystats fails to
339        reset power values.
340      device_errors.DeviceVersionError: If device is not L or higher.
341    """
342    def battery_updates_disabled():
343      return self.GetCharging() is False
344
345    self._ClearPowerData()
346    self._device.RunShellCommand(['dumpsys', 'battery', 'set', 'ac', '0'],
347                                 check_return=True)
348    self._device.RunShellCommand(['dumpsys', 'battery', 'set', 'usb', '0'],
349                                 check_return=True)
350    timeout_retry.WaitFor(battery_updates_disabled, wait_period=1)
351
352  # TODO(rnephew): Make private when all use cases can use the context manager.
353  @decorators.WithTimeoutAndRetriesFromInstance()
354  def EnableBatteryUpdates(self, timeout=None, retries=None):
355    """Restarts device charging so that dumpsys no longer collects power data.
356
357    Args:
358      timeout: timeout in seconds
359      retries: number of retries
360
361    Raises:
362      device_errors.DeviceVersionError: If device is not L or higher.
363    """
364    def battery_updates_enabled():
365      return (self.GetCharging()
366              or not bool('UPDATES STOPPED' in self._device.RunShellCommand(
367                  ['dumpsys', 'battery'], check_return=True)))
368
369    self._device.RunShellCommand(['dumpsys', 'battery', 'reset'],
370                                 check_return=True)
371    timeout_retry.WaitFor(battery_updates_enabled, wait_period=1)
372
373  @contextlib.contextmanager
374  def BatteryMeasurement(self, timeout=None, retries=None):
375    """Context manager that enables battery data collection. It makes
376    the device appear to stop charging so that dumpsys will start collecting
377    power data since last charge. Once the with block is exited, charging is
378    resumed and power data since last charge is no longer collected.
379
380    Only for devices L and higher.
381
382    Example usage:
383      with BatteryMeasurement():
384        browser_actions()
385        get_power_data() # report usage within this block
386      after_measurements() # Anything that runs after power
387                           # measurements are collected
388
389    Args:
390      timeout: timeout in seconds
391      retries: number of retries
392
393    Raises:
394      device_errors.DeviceVersionError: If device is not L or higher.
395    """
396    if self._device.build_version_sdk < version_codes.LOLLIPOP:
397      raise device_errors.DeviceVersionError('Device must be L or higher.')
398    try:
399      self.DisableBatteryUpdates(timeout=timeout, retries=retries)
400      yield
401    finally:
402      self.EnableBatteryUpdates(timeout=timeout, retries=retries)
403
404  def _DischargeDevice(self, percent, wait_period=120):
405    """Disables charging and waits for device to discharge given amount
406
407    Args:
408      percent: level of charge to discharge.
409
410    Raises:
411      ValueError: If percent is not between 1 and 99.
412    """
413    battery_level = int(self.GetBatteryInfo().get('level'))
414    if not 0 < percent < 100:
415      raise ValueError('Discharge amount(%s) must be between 1 and 99'
416                       % percent)
417    if battery_level is None:
418      logging.warning('Unable to find current battery level. Cannot discharge.')
419      return
420    # Do not discharge if it would make battery level too low.
421    if percent >= battery_level - 10:
422      logging.warning('Battery is too low or discharge amount requested is too '
423                      'high. Cannot discharge phone %s percent.', percent)
424      return
425
426    self._HardwareSetCharging(False)
427
428    def device_discharged():
429      self._HardwareSetCharging(True)
430      current_level = int(self.GetBatteryInfo().get('level'))
431      logging.info('current battery level: %s', current_level)
432      if battery_level - current_level >= percent:
433        return True
434      self._HardwareSetCharging(False)
435      return False
436
437    timeout_retry.WaitFor(device_discharged, wait_period=wait_period)
438
439  def ChargeDeviceToLevel(self, level, wait_period=60):
440    """Enables charging and waits for device to be charged to given level.
441
442    Args:
443      level: level of charge to wait for.
444      wait_period: time in seconds to wait between checking.
445    Raises:
446      device_errors.DeviceChargingError: If error while charging is detected.
447    """
448    self.SetCharging(True)
449    charge_status = {
450        'charge_failure_count': 0,
451        'last_charge_value': 0
452    }
453    def device_charged():
454      battery_level = self.GetBatteryInfo().get('level')
455      if battery_level is None:
456        logging.warning('Unable to find current battery level.')
457        battery_level = 100
458      else:
459        logging.info('current battery level: %s', battery_level)
460        battery_level = int(battery_level)
461
462      # Use > so that it will not reset if charge is going down.
463      if battery_level > charge_status['last_charge_value']:
464        charge_status['last_charge_value'] = battery_level
465        charge_status['charge_failure_count'] = 0
466      else:
467        charge_status['charge_failure_count'] += 1
468
469      if (not battery_level >= level
470          and charge_status['charge_failure_count'] >= _MAX_CHARGE_ERROR):
471        raise device_errors.DeviceChargingError(
472            'Device not charging properly. Current level:%s Previous level:%s'
473             % (battery_level, charge_status['last_charge_value']))
474      return battery_level >= level
475
476    timeout_retry.WaitFor(device_charged, wait_period=wait_period)
477
478  def LetBatteryCoolToTemperature(self, target_temp, wait_period=180):
479    """Lets device sit to give battery time to cool down
480    Args:
481      temp: maximum temperature to allow in tenths of degrees c.
482      wait_period: time in seconds to wait between checking.
483    """
484    def cool_device():
485      temp = self.GetBatteryInfo().get('temperature')
486      if temp is None:
487        logging.warning('Unable to find current battery temperature.')
488        temp = 0
489      else:
490        logging.info('Current battery temperature: %s', temp)
491      if int(temp) <= target_temp:
492        return True
493      else:
494        if self._cache['profile']['name'] == 'Nexus 5':
495          self._DischargeDevice(1)
496        return False
497
498    self._DiscoverDeviceProfile()
499    self.EnableBatteryUpdates()
500    logging.info('Waiting for the device to cool down to %s (0.1 C)',
501                 target_temp)
502    timeout_retry.WaitFor(cool_device, wait_period=wait_period)
503
504  @decorators.WithTimeoutAndRetriesFromInstance()
505  def SetCharging(self, enabled, timeout=None, retries=None):
506    """Enables or disables charging on the device.
507
508    Args:
509      enabled: A boolean indicating whether charging should be enabled or
510        disabled.
511      timeout: timeout in seconds
512      retries: number of retries
513    """
514    if self.GetCharging() == enabled:
515      logging.warning('Device charging already in expected state: %s', enabled)
516      return
517
518    self._DiscoverDeviceProfile()
519    if enabled:
520      if self._cache['profile']['enable_command']:
521        self._HardwareSetCharging(enabled)
522      else:
523        logging.info('Unable to enable charging via hardware. '
524                     'Falling back to software enabling.')
525        self.EnableBatteryUpdates()
526    else:
527      if self._cache['profile']['enable_command']:
528        self._ClearPowerData()
529        self._HardwareSetCharging(enabled)
530      else:
531        logging.info('Unable to disable charging via hardware. '
532                     'Falling back to software disabling.')
533        self.DisableBatteryUpdates()
534
535  def _HardwareSetCharging(self, enabled, timeout=None, retries=None):
536    """Enables or disables charging on the device.
537
538    Args:
539      enabled: A boolean indicating whether charging should be enabled or
540        disabled.
541      timeout: timeout in seconds
542      retries: number of retries
543
544    Raises:
545      device_errors.CommandFailedError: If method of disabling charging cannot
546        be determined.
547    """
548    self._DiscoverDeviceProfile()
549    if not self._cache['profile']['enable_command']:
550      raise device_errors.CommandFailedError(
551          'Unable to find charging commands.')
552
553    command = (self._cache['profile']['enable_command'] if enabled
554               else self._cache['profile']['disable_command'])
555
556    def verify_charging():
557      return self.GetCharging() == enabled
558
559    self._device.RunShellCommand(
560        command, check_return=True, as_root=True, large_output=True)
561    timeout_retry.WaitFor(verify_charging, wait_period=1)
562
563  @contextlib.contextmanager
564  def PowerMeasurement(self, timeout=None, retries=None):
565    """Context manager that enables battery power collection.
566
567    Once the with block is exited, charging is resumed. Will attempt to disable
568    charging at the hardware level, and if that fails will fall back to software
569    disabling of battery updates.
570
571    Only for devices L and higher.
572
573    Example usage:
574      with PowerMeasurement():
575        browser_actions()
576        get_power_data() # report usage within this block
577      after_measurements() # Anything that runs after power
578                           # measurements are collected
579
580    Args:
581      timeout: timeout in seconds
582      retries: number of retries
583    """
584    try:
585      self.SetCharging(False, timeout=timeout, retries=retries)
586      yield
587    finally:
588      self.SetCharging(True, timeout=timeout, retries=retries)
589
590  def _ClearPowerData(self):
591    """Resets battery data and makes device appear like it is not
592    charging so that it will collect power data since last charge.
593
594    Returns:
595      True if power data cleared.
596      False if power data clearing is not supported (pre-L)
597
598    Raises:
599      device_errors.DeviceVersionError: If power clearing is supported,
600        but fails.
601    """
602    if self._device.build_version_sdk < version_codes.LOLLIPOP:
603      logging.warning('Dumpsys power data only available on 5.0 and above. '
604                      'Cannot clear power data.')
605      return False
606
607    self._device.RunShellCommand(
608        ['dumpsys', 'battery', 'set', 'usb', '1'], check_return=True)
609    self._device.RunShellCommand(
610        ['dumpsys', 'battery', 'set', 'ac', '1'], check_return=True)
611    self._device.RunShellCommand(
612        ['dumpsys', 'batterystats', '--reset'], check_return=True)
613    battery_data = self._device.RunShellCommand(
614        ['dumpsys', 'batterystats', '--charged', '-c'],
615        check_return=True, large_output=True)
616    for line in battery_data:
617      l = line.split(',')
618      if (len(l) > _PWI_POWER_CONSUMPTION_INDEX and l[_ROW_TYPE_INDEX] == 'pwi'
619          and l[_PWI_POWER_CONSUMPTION_INDEX] != 0):
620        self._device.RunShellCommand(
621            ['dumpsys', 'battery', 'reset'], check_return=True)
622        raise device_errors.CommandFailedError(
623            'Non-zero pmi value found after reset.')
624    self._device.RunShellCommand(
625        ['dumpsys', 'battery', 'reset'], check_return=True)
626    return True
627
628  def _DiscoverDeviceProfile(self):
629    """Checks and caches device information.
630
631    Returns:
632      True if profile is found, false otherwise.
633    """
634
635    if 'profile' in self._cache:
636      return True
637    for profile in _DEVICE_PROFILES:
638      if self._device.product_model == profile['name']:
639        self._cache['profile'] = profile
640        return True
641    self._cache['profile'] = {
642        'name': None,
643        'witness_file': None,
644        'enable_command': None,
645        'disable_command': None,
646        'charge_counter': None,
647        'voltage': None,
648        'current': None,
649    }
650    return False
651