policy_DeviceCharging: Add BatteryChargeMode client test

Add a client and server tests for the BatteryChargeMode device policy,
which controls device charging. The ChargingPolicyTest class is in a
library because it will be used in the following CLs.

BUG=b:139201701
TEST=test_that -b sarien $DUT policy_DeviceChargingServer.BatteryChargeMode

Change-Id: Ib6bb4ebbb7bc1a8efef2845b78341f2f128e9d47
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/third_party/autotest/+/1867573
Reviewed-by: Derek Beckett <dbeckett@chromium.org>
Commit-Queue: Daniel Campello <campello@chromium.org>
Tested-by: Daniel Campello <campello@chromium.org>
diff --git a/client/cros/enterprise/charging_policy_tests.py b/client/cros/enterprise/charging_policy_tests.py
new file mode 100644
index 0000000..8caa546
--- /dev/null
+++ b/client/cros/enterprise/charging_policy_tests.py
@@ -0,0 +1,74 @@
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from autotest_lib.client.common_lib import error
+from autotest_lib.client.cros.enterprise import enterprise_policy_base
+from autotest_lib.client.cros.power import power_status
+
+class ChargingPolicyTest(enterprise_policy_base.EnterprisePolicyTest):
+    """
+    A Client test that verifies that AC usage and battery charging is consistent
+    with policy settings. As of this writing, these features are only present on
+    the Wilco platform.
+    """
+    # The Wilco EC updates it's charging behavior every 10 seconds,
+    # so give ourselves 15 seconds to notice a change in behavior.
+    POLICY_CHANGE_TIMEOUT = 15
+
+    def run_once(self,
+                 test_cases,
+                 min_battery_level,
+                 prep_policies):
+        """
+        Test a collection of cases.
+
+        @param test_cases: Collection of (policies, expected_behavior) pairs,
+                           where expected_behavior is one of values accepted by
+                           power_status.poll_for_charging_behavior().
+        @param min_battery_level: For the policy to affect the behavior
+                                  correctly, the battery level may need to be
+                                  above a certain percentage.
+        @param prep_policies: To test that policies P1 cause behavior B1, we
+                              need to start in a state P2 where behavior B2 is
+                              not B1, so we can notice the change to B1.
+                              prep_policies is a dict that maps B1 => (P2, B2),
+                              so that we can look up how to prep for testing
+                              P1.
+        """
+        self.setup_case(enroll=True)
+
+        failures = []
+        for policies, expected_behavior in test_cases:
+            setup_policies, prep_behavior = prep_policies[expected_behavior]
+            err = self._test_policies(setup_policies, prep_behavior,
+                                      min_battery_level)
+            if err is not None:
+                failures.append(err)
+
+            # Now that we are set up, test the actual test case.
+            err = self._test_policies(policies, expected_behavior,
+                                      min_battery_level)
+            if err is not None:
+                failures.append(err)
+        if failures:
+            raise error.TestFail('Failed the following cases: {}'.format(
+                str(failures)))
+
+    def _test_policies(self, policies, expected_behavior, min_battery_level):
+        self.update_policies(device_policies=policies)
+        try:
+            self._assert_battery_is_testable(min_battery_level)
+            power_status.poll_for_charging_behavior(expected_behavior,
+                                                    self.POLICY_CHANGE_TIMEOUT)
+        except BaseException as e:
+            msg = ('Expected to be {} using policies {}. Got this instead: {}'.
+                format(expected_behavior, policies, str(e)))
+            return msg
+        return None
+
+    def _assert_battery_is_testable(self, min_battery_level):
+        status = power_status.get_status()
+        if status.battery_full():
+            raise error.TestError('The battery is full, but should not be')
+        status.assert_battery_in_range(min_battery_level, 100)
diff --git a/client/cros/power/power_status.py b/client/cros/power/power_status.py
index 9dbfddc..4a64d7b 100644
--- a/client/cros/power/power_status.py
+++ b/client/cros/power/power_status.py
@@ -20,6 +20,7 @@
 
 from autotest_lib.client.bin import utils
 from autotest_lib.client.common_lib import error, enum
+from autotest_lib.client.common_lib.utils import poll_for_condition_ex
 from autotest_lib.client.cros import kernel_trace
 from autotest_lib.client.cros.power import power_utils
 
@@ -510,6 +511,16 @@
 
         return self.battery.status.rstrip() == 'Discharging'
 
+    def battery_full(self):
+        """
+        Returns true if battery is currently full or false otherwise.
+        """
+        if not self.battery_path:
+            logging.warn('Unable to determine battery fullness status')
+            return False
+
+        return self.battery.status.rstrip() == 'Full'
+
 
     def battery_discharge_ok_on_ac(self):
         """Returns True if battery is ok to discharge on AC presently.
@@ -549,6 +560,13 @@
             raise error.TestError('Initial charge (%f) less than min (%f)'
                       % (percent_initial_charge, percent_initial_charge_min))
 
+    def assert_battery_in_range(self, min_level, max_level):
+        """Raise a error.TestFail if the battery level is not in range."""
+        current_percent = self.percent_current_charge()
+        if not (min_level <= current_percent <= max_level):
+            raise error.TestFail('battery must be in range [{}, {}]'.format(
+                                 min_level, max_level))
+
 
 def get_status():
     """
@@ -562,6 +580,51 @@
     return status
 
 
+def poll_for_charging_behavior(behavior, timeout):
+    """
+    Wait up to |timeout| seconds for the charging behavior to become |behavior|.
+
+    @param behavior: One of 'ON_AC_AND_CHARGING',
+                            'ON_AC_AND_NOT_CHARGING',
+                            'NOT_ON_AC_AND_NOT_CHARGING'.
+    @param timeout: in seconds.
+
+    @raises: error.TestFail if the behavior does not match in time, or another
+             exception if something else fails along the way.
+    """
+    ps = get_status()
+
+    def _verify_on_AC_and_charging():
+        ps.refresh()
+        if not ps.on_ac():
+            raise error.TestFail('Device is not on AC, but should be')
+        if not ps.battery_charging():
+            raise error.TestFail('Device is not charging, but should be')
+        return True
+
+    def _verify_on_AC_and_not_charging():
+        ps.refresh()
+        if not ps.on_ac():
+            raise error.TestFail('Device is not on AC, but should be')
+        if ps.battery_charging():
+            raise error.TestFail('Device is charging, but should not be')
+        return True
+
+    def _verify_not_on_AC_and_not_charging():
+        ps.refresh()
+        if ps.on_ac():
+            raise error.TestFail('Device is on AC, but should not be')
+        return True
+
+    poll_functions = {
+        'ON_AC_AND_CHARGING'        : _verify_on_AC_and_charging,
+        'ON_AC_AND_NOT_CHARGING'    : _verify_on_AC_and_not_charging,
+        'NOT_ON_AC_AND_NOT_CHARGING': _verify_not_on_AC_and_not_charging,
+    }
+    poll_for_condition_ex(poll_functions[behavior],
+                          timeout=timeout,
+                          sleep_interval=1)
+
 class AbstractStats(object):
     """
     Common superclass for measurements of percentages per state over time.
diff --git a/client/site_tests/policy_DeviceCharging/control b/client/site_tests/policy_DeviceCharging/control
new file mode 100644
index 0000000..b662982
--- /dev/null
+++ b/client/site_tests/policy_DeviceCharging/control
@@ -0,0 +1,24 @@
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+AUTHOR = 'ncrews'
+NAME = 'policy_DeviceCharging'
+TIME = 'LONG'
+TEST_CATEGORY = 'General'
+TEST_CLASS = 'enterprise'
+TEST_TYPE = 'client'
+
+DOC = '''
+Verifies that the DeviceBatteryChargeMode policy works.
+
+This test is kicked off via policy_DeviceChargingServer server test. It requires
+a Servo v4 and Servo Micro attached to the DUT. Also, it requires that the
+battery is not full, and that the battery is above |MIN_BATTERY_LEVEL|, so that
+the policies can get fully tested. The server test should take care of this
+setup.
+'''
+
+args_dict = utils.args_to_dict(args)
+
+job.run_test('policy_DeviceCharging', **args_dict)
diff --git a/client/site_tests/policy_DeviceCharging/policy_DeviceCharging.py b/client/site_tests/policy_DeviceCharging/policy_DeviceCharging.py
new file mode 100644
index 0000000..205d8c6
--- /dev/null
+++ b/client/site_tests/policy_DeviceCharging/policy_DeviceCharging.py
@@ -0,0 +1,14 @@
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from autotest_lib.client.cros.enterprise import charging_policy_tests
+
+
+class policy_DeviceCharging(charging_policy_tests.ChargingPolicyTest):
+    """
+    Client test for device policies that change charging behavior.
+
+    Everything is taken care of in the superclass.
+    """
+    version = 1
diff --git a/server/cros/enterprise/__init__.py b/server/cros/enterprise/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/server/cros/enterprise/__init__.py
diff --git a/server/cros/enterprise/device_policy_test.py b/server/cros/enterprise/device_policy_test.py
new file mode 100644
index 0000000..390d558
--- /dev/null
+++ b/server/cros/enterprise/device_policy_test.py
@@ -0,0 +1,27 @@
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from autotest_lib.client.common_lib.cros import tpm_utils
+from autotest_lib.server import test
+
+class DevicePolicyServerTest(test.test):
+    """
+    A server test that verifies a device policy, by refreshing the TPM so the
+    DUT is not owned, and then kicking off the client test.
+
+    This class takes charge of the TPM initialization and cleanup, but child
+    tests should implement their own run_once().
+    """
+    version = 1
+
+    def warmup(self, host, *args, **kwargs):
+        """Clear TPM to ensure that client test can enroll device."""
+        super(DevicePolicyServerTest, self).warmup(host, *args, **kwargs)
+        self.host = host
+        tpm_utils.ClearTPMIfOwned(self.host)
+
+    def cleanup(self):
+        """Get the DUT back to a clean state."""
+        tpm_utils.ClearTPMIfOwned(self.host)
+        super(DevicePolicyServerTest, self).cleanup()
diff --git a/server/cros/power/utils.py b/server/cros/power/utils.py
new file mode 100644
index 0000000..253c8a8
--- /dev/null
+++ b/server/cros/power/utils.py
@@ -0,0 +1,40 @@
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Power utils for server tests."""
+
+from autotest_lib.server import autotest
+from autotest_lib.server.cros.power import servo_charger
+
+def put_host_battery_in_range(host, min_level, max_level, timeout):
+    """
+    Charges or drains the host's battery to the specified range within the
+    timeout. This uses a servo v4 and either the power_BatteryCharge or the
+    power_BatteryDrain client test.
+
+    @param host: DUT to use
+    @param min_level: battery percentage
+    @param max_level: battery percentage
+    @param timeout: in seconds
+
+    @throws: A TestFail error if getting the current battery level, setting the
+             servo's charge state, or running either of the client tests fails.
+    """
+    current_level = host.get_battery_percentage()
+    if current_level >= min_level and current_level <= max_level:
+        return
+
+    autotest_client = autotest.Autotest(host)
+    charge_manager = servo_charger.ServoV4ChargeManager(host, host.servo)
+    if current_level < min_level:
+        charge_manager.start_charging()
+        autotest_client.run_test('power_BatteryCharge',
+                                 max_run_time=timeout,
+                                 percent_target_charge=min_level,
+                                 use_design_charge_capacity=False)
+    if current_level > max_level:
+        charge_manager.stop_charging()
+        autotest_client.run_test('power_BatteryDrain',
+                                 drain_to_percent=max_level,
+                                 drain_timeout=timeout)
diff --git a/server/site_tests/policy_DeviceChargingServer/control.BatteryChargeMode b/server/site_tests/policy_DeviceChargingServer/control.BatteryChargeMode
new file mode 100644
index 0000000..74ada7b
--- /dev/null
+++ b/server/site_tests/policy_DeviceChargingServer/control.BatteryChargeMode
@@ -0,0 +1,79 @@
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from autotest_lib.client.common_lib import utils
+from autotest_lib.server.hosts import cros_host
+
+AUTHOR = 'ncrews'
+DEPENDENCIES = "servo"
+NAME = 'policy_DeviceChargingServer.BatteryChargeMode'
+TIME = 'LONG'
+TEST_CATEGORY = 'General'
+TEST_CLASS = 'enterprise'
+TEST_TYPE = 'server'
+ATTRIBUTES = 'suite:ent-nightly, suite:policy'
+
+DOC = """
+Ensures the DUT's battery level is in a testable range, clears the TPM if
+needed, and then runs the specified client test to verify charging behavior
+is consistent with policies.
+"""
+
+args_dict = utils.args_to_dict(args)
+servo_args = cros_host.CrosHost.get_servo_arguments(args_dict)
+
+client_test = 'policy_DeviceCharging'
+
+# When DeviceBatteryChargeMode is set to BATTERY_CHARGE_PRIMARILY_AC_USE, then
+# the DUT will not charge when above 86%. In order to test this, we need to be
+# above this threshold.
+MIN_BATTERY_LEVEL = 87
+
+# A test case consists of the policies, plus the expected power behavior.
+TEST_CASES = [
+    ({'DeviceBatteryChargeMode': 1}, # BATTERY_CHARGE_STANDARD
+     'ON_AC_AND_CHARGING'),
+    ({'DeviceBatteryChargeMode': 2}, # BATTERY_CHARGE_EXPRESS_CHARGE
+     'ON_AC_AND_CHARGING'),
+    ({'DeviceBatteryChargeMode': 3}, # BATTERY_CHARGE_PRIMARILY_AC_USE
+     'ON_AC_AND_CHARGING'),
+    ({'DeviceBatteryChargeMode': 4}, # `BATTERY_CHARGE_ADAPTIVE
+     'ON_AC_AND_CHARGING'),
+    ({'DeviceBatteryChargeMode': 5, # BATTERY_CHARGE_CUSTOM
+      'DeviceBatteryChargeCustomStartCharging': 50,
+      'DeviceBatteryChargeCustomStopCharging': 60},
+     'ON_AC_AND_NOT_CHARGING'),
+    ({'DeviceBatteryChargeMode': 5, # BATTERY_CHARGE_CUSTOM
+      'DeviceBatteryChargeCustomStartCharging': 50,
+      'DeviceBatteryChargeCustomStopCharging': 100},
+     'ON_AC_AND_CHARGING'),
+]
+
+# These are used to cleanup the DUT and to prep the DUT before each test case.
+# See the test for more info.
+ON_AC_AND_CHARGING_POLICIES = {
+    'DeviceBatteryChargeMode': 1, # BATTERY_CHARGE_STANDARD
+}
+ON_AC_AND_NOT_CHARGING_POLICIES = {
+    'DeviceBatteryChargeMode': 5, # BATTERY_CHARGE_CUSTOM
+    'DeviceBatteryChargeCustomStartCharging': 50,
+    'DeviceBatteryChargeCustomStopCharging': 60,
+}
+PREP_POLICIES = {
+    'ON_AC_AND_CHARGING'         : (ON_AC_AND_NOT_CHARGING_POLICIES,
+                                    'ON_AC_AND_NOT_CHARGING'),
+    'ON_AC_AND_NOT_CHARGING'     : (ON_AC_AND_CHARGING_POLICIES,
+                                    'ON_AC_AND_CHARGING'),
+}
+
+def run(machine):
+    host = hosts.create_host(machine, servo_args=servo_args)
+    job.run_test('policy_DeviceChargingServer',
+                 host=host,
+                 client_test=client_test,
+                 test_cases=TEST_CASES,
+                 min_battery_level=MIN_BATTERY_LEVEL,
+                 prep_policies=PREP_POLICIES)
+
+parallel_simple(run, machines)
diff --git a/server/site_tests/policy_DeviceChargingServer/policy_DeviceChargingServer.py b/server/site_tests/policy_DeviceChargingServer/policy_DeviceChargingServer.py
new file mode 100644
index 0000000..a9666db
--- /dev/null
+++ b/server/site_tests/policy_DeviceChargingServer/policy_DeviceChargingServer.py
@@ -0,0 +1,46 @@
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from autotest_lib.server import autotest
+from autotest_lib.server.cros.enterprise import device_policy_test
+from autotest_lib.server.cros.power import servo_charger, utils
+
+
+class policy_DeviceChargingServer(device_policy_test.DevicePolicyServerTest):
+    """
+    A variant of DevicePolicyServerTest that verifies charging policy behavior.
+    As of this writing, these features are only present on the Wilco platform.
+
+    It requires a Servo v4 USB-C and Servo Micro attached to the DUT.
+    """
+    version = 1
+
+    # To be in a testable state, the DUT has to have low enough battery that
+    # it can charge. Let's give ourselves a buffer for when the battery
+    # inevitably charges a bit in the middle of the test.
+    MAX_BATTERY_LEVEL = 95
+
+    # Allow 15 minutes for battery to charge or drain to the needed range.
+    BATTERY_CHANGE_TIMEOUT = 15 * 60
+
+    def run_once(self, host, client_test, test_cases, min_battery_level,
+                 prep_policies):
+        """
+        Ensures the DUT's battery level is low enough to charge and above the
+        specified level, and then runs the specified client test. Assumes any
+        TPM stuff is dealt with in the parent class.
+        """
+        utils.put_host_battery_in_range(host, min_battery_level,
+                                        self.MAX_BATTERY_LEVEL,
+                                        self.BATTERY_CHANGE_TIMEOUT)
+        charger = servo_charger.ServoV4ChargeManager(host, host.servo)
+        charger.start_charging()
+
+        autotest_client = autotest.Autotest(host)
+        autotest_client.run_test(
+                client_test,
+                check_client_result=True,
+                test_cases=test_cases,
+                min_battery_level=min_battery_level,
+                prep_policies=prep_policies)