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