Repository: giampaolo/psutil
Pull request #963 (2017-02-01 16:04:35)

955 battery sensors


Patch:
diff --git a/README.rst b/README.rst
index f890fcb3..ddcdbbf6 100644
--- a/README.rst
+++ b/README.rst
@@ -195,6 +195,9 @@ Sensors
                   shwtemp(label='Core 1', current=52.0, high=100.0, critical=100.0),
                   shwtemp(label='Core 2', current=45.0, high=100.0, critical=100.0),
                   shwtemp(label='Core 3', current=47.0, high=100.0, critical=100.0)]}
+    >>>
+    >>> psutil.sensors_battery()
+    sbattery(percent=93, secsleft=16628, power_plugged=False)
 
 Other system info
 =================
diff --git a/docs/index.rst b/docs/index.rst
index e5431e62..ec35d769 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -641,15 +641,49 @@ Sensors
   See also `temperatures.py <https://github.com/giampaolo/psutil/blob/master/scripts/temperatures.py>`__
   for an example application.
 
+  Availability: Linux
+
+  .. versionadded:: 5.1.0
+
   .. warning::
 
-    This API is experimental. Backwards incompatible changes may occur if
+    This API is experimental. Backward incompatible changes may occur if
     deemed necessary.
 
-  Availability: Linux
+.. function:: sensors_battery()
 
-  .. versionadded:: 5.1.0
+  Return battery status information as a namedtuple including the following
+  values. If no battery is installed returns ``None``.
+
+  - **percent**: battery power left as a percentage.
+  - **secsleft**: a rough approximation of how many seconds are left before the
+    battery runs out of power.
+    If the AC power cable is connected this is set to
+    :data:`psutil.POWER_TIME_UNLIMITED <psutil.POWER_TIME_UNLIMITED>`.
+    If it can't be determined it is set to
+    :data:`psutil.POWER_TIME_UNKNOWN <psutil.POWER_TIME_UNKNOWN>`.
+  - **power_plugged**: ``True`` if the AC power cable is connected.
+
+  Example::
+
+    >>> import psutil
+    >>>
+    >>> def secs2hours(secs):
+    ...     mm, ss = divmod(secs, 60)
+    ...     hh, mm = divmod(mm, 60)
+    ...     return "%d:%02d:%02d" % (hh, mm, ss)
+    ...
+    >>> battery = psutil.sensors_battery()
+    >>> battery
+    sbattery(percent=93, secsleft=16628, power_plugged=False)
+    >>> print("charge = %s%%, time left = %s" % (batt.percent, secs2hours(batt.secsleft)))
+    charge = 93%, time left = 4:37:08
+
+  See also `battery.py <https://github.com/giampaolo/psutil/blob/master/scripts/battery.py>`__
+
+  Availability: Linux, Windows, FreeBSD
 
+  .. versionadded:: 5.1.0
 
 
 Other system info
@@ -2022,6 +2056,16 @@ Constants
 
   .. versionadded:: 3.0.0
 
+.. _const-power:
+.. data:: POWER_TIME_UNKNOWN
+.. data:: POWER_TIME_UNLIMITED
+
+  Whether the remaining time of the battery cannot be determined or is
+  unlimited.
+  May be assigned to :func:`psutil.sensors_battery()`'s *secsleft* field.
+
+  .. versionadded:: 5.1.0
+
 .. _const-version-info:
 .. data:: version_info
 
diff --git a/psutil/__init__.py b/psutil/__init__.py
index e13866a3..9054c615 100644
--- a/psutil/__init__.py
+++ b/psutil/__init__.py
@@ -171,6 +171,8 @@ __all__ = [
 
     "NIC_DUPLEX_FULL", "NIC_DUPLEX_HALF", "NIC_DUPLEX_UNKNOWN",
 
+    "POWER_TIME_UNKNOWN", "POWER_TIME_UNLIMITED",
+
     "BSD", "FREEBSD", "LINUX", "NETBSD", "OPENBSD", "OSX", "POSIX", "SUNOS",
     "WINDOWS",
 
@@ -185,7 +187,7 @@ __all__ = [
     "net_io_counters", "net_connections", "net_if_addrs",           # network
     "net_if_stats",
     "disk_io_counters", "disk_partitions", "disk_usage",            # disk
-    # "sensors_temperatures",                                       # sensors
+    # "sensors_temperatures", "sensors_battery",                    # sensors
     "users", "boot_time",                                           # others
 ]
 __all__.extend(_psplatform.__extra__all__)
@@ -193,6 +195,8 @@ __author__ = "Giampaolo Rodola'"
 __version__ = "5.1.0"
 version_info = tuple([int(num) for num in __version__.split('.')])
 AF_LINK = _psplatform.AF_LINK
+POWER_TIME_UNLIMITED = _common.POWER_TIME_UNLIMITED
+POWER_TIME_UNKNOWN = _common.POWER_TIME_UNKNOWN
 _TOTAL_PHYMEM = None
 _timer = getattr(time, 'monotonic', time.time)
 
@@ -2186,6 +2190,7 @@ def net_if_stats():
 # =====================================================================
 
 
+# Linux
 if hasattr(_psplatform, "sensors_temperatures"):
 
     def sensors_temperatures(fahrenheit=False):
@@ -2225,6 +2230,24 @@ if hasattr(_psplatform, "sensors_temperatures"):
     __all__.append("sensors_temperatures")
 
 
+# Linux, Windows, FreeBSD
+if hasattr(_psplatform, "sensors_battery"):
+
+    def sensors_battery():
+        """Return battery information. If no battery is installed
+        returns None.
+
+        - percent: battery power left as a percentage.
+        - secsleft: a rough approximation of how many seconds are left
+                    before the battery runs out of power.
+                    May be POWER_TIME_UNLIMITED or POWER_TIME_UNLIMITED.
+        - power_plugged: True if the AC power cable is connected.
+        """
+        return _psplatform.sensors_battery()
+
+    __all__.append("sensors_battery")
+
+
 # =====================================================================
 # --- other system related functions
 # =====================================================================
diff --git a/psutil/_common.py b/psutil/_common.py
index 0d965ac1..3b68b6d3 100644
--- a/psutil/_common.py
+++ b/psutil/_common.py
@@ -121,6 +121,17 @@ else:
 
     globals().update(NicDuplex.__members__)
 
+# sensors_battery()
+if enum is None:
+    POWER_TIME_UNKNOWN = -1
+    POWER_TIME_UNLIMITED = -2
+else:
+    class BatteryTime(enum.IntEnum):
+        POWER_TIME_UNKNOWN = -1
+        POWER_TIME_UNLIMITED = -2
+
+    globals().update(BatteryTime.__members__)
+
 
 # ===================================================================
 # --- namedtuples
@@ -161,6 +172,8 @@ scpufreq = namedtuple('scpufreq', ['current', 'min', 'max'])
 # psutil.sensors_temperatures()
 shwtemp = namedtuple(
     'shwtemp', ['label', 'current', 'high', 'critical'])
+# psutil.sensors_battery()
+sbattery = namedtuple('sbattery', ['percent', 'secsleft', 'power_plugged'])
 
 # --- for Process methods
 
diff --git a/psutil/_psbsd.py b/psutil/_psbsd.py
index 022f5758..fb141cf2 100644
--- a/psutil/_psbsd.py
+++ b/psutil/_psbsd.py
@@ -395,6 +395,25 @@ def net_connections(kind):
     return list(ret)
 
 
+# =====================================================================
+#  --- sensors
+# =====================================================================
+
+
+if FREEBSD:
+
+    def sensors_battery():
+        percent, minsleft, power_plugged = cext.sensors_battery()
+        power_plugged = power_plugged == 1
+        if power_plugged:
+            secsleft = _common.POWER_TIME_UNLIMITED
+        elif minsleft == -1:
+            secsleft = _common.POWER_TIME_UNKNOWN
+        else:
+            secsleft = minsleft * 60
+        return _common.sbattery(percent, secsleft, power_plugged)
+
+
 # =====================================================================
 #  --- other system functions
 # =====================================================================
diff --git a/psutil/_pslinux.py b/psutil/_pslinux.py
index 16de79e5..0d5f6ecb 100644
--- a/psutil/_pslinux.py
+++ b/psutil/_pslinux.py
@@ -62,6 +62,7 @@ __extra__all__ = [
 # --- constants
 # =====================================================================
 
+POWER_SUPPLY_PATH = "/sys/class/power_supply"
 
 HAS_SMAPS = os.path.exists('/proc/%s/smaps' % os.getpid())
 HAS_PRLIMIT = hasattr(cext, "linux_prlimit")
@@ -1102,6 +1103,28 @@ if os.path.exists('/sys/class/hwmon'):
         return ret
 
 
+def sensors_battery():
+    root = os.path.join(POWER_SUPPLY_PATH, "BAT0")
+    if not os.path.exists(root):
+        return None
+
+    power_plugged = \
+        cat("/sys/class/power_supply/AC0/online", fallback=b"0") == b"1"
+    energy_now = int(cat(root + "/energy_now"))
+    power_now = int(cat(root + "/power_now"))
+    percent = int(cat(root + "/capacity"))
+
+    if power_plugged:
+        secsleft = _common.POWER_TIME_UNLIMITED
+    else:
+        try:
+            secsleft = int(energy_now / power_now * 3600)
+        except ZeroDivisionError:
+            secsleft = _common.POWER_TIME_UNKNOWN
+
+    return _common.sbattery(percent, secsleft, power_plugged)
+
+
 # =====================================================================
 # --- other system functions
 # =====================================================================
diff --git a/psutil/_psutil_bsd.c b/psutil/_psutil_bsd.c
index adcedf79..de748dcc 100644
--- a/psutil/_psutil_bsd.c
+++ b/psutil/_psutil_bsd.c
@@ -933,6 +933,10 @@ PsutilMethods[] = {
 #if defined(PSUTIL_FREEBSD) || defined(PSUTIL_NETBSD)
     {"net_connections", psutil_net_connections, METH_VARARGS,
      "Return system-wide open connections."},
+#endif
+#if defined(PSUTIL_FREEBSD)
+    {"sensors_battery", psutil_sensors_battery, METH_VARARGS,
+     "Return battery information."},
 #endif
     {NULL, NULL, 0, NULL}
 };
diff --git a/psutil/_psutil_windows.c b/psutil/_psutil_windows.c
index 4caace7d..74edc6cd 100644
--- a/psutil/_psutil_windows.c
+++ b/psutil/_psutil_windows.c
@@ -3456,6 +3456,31 @@ error:
 }
 
 
+/*
+ * Return battery usage stats.
+ */
+static PyObject *
+psutil_sensors_battery(PyObject *self, PyObject *args) {
+    SYSTEM_POWER_STATUS sps;
+
+    if (GetSystemPowerStatus(&sps) == 0) {
+        PyErr_SetFromWindowsErr(0);
+        return NULL;
+    }
+    return Py_BuildValue(
+        "iiiI",
+        sps.ACLineStatus,  // whether AC is connected: 0=no, 1=yes, 255=unknown
+        // status flag:
+        // 1, 2, 4 = high, low, critical
+        // 8 = charging
+        // 128 = no battery
+        sps.BatteryFlag,
+        sps.BatteryLifePercent,  // percent
+        sps.BatteryLifeTime  // remaining secs
+    );
+}
+
+
 // ------------------------ Python init ---------------------------
 
 static PyMethodDef
@@ -3562,6 +3587,8 @@ PsutilMethods[] = {
      "Return NICs stats."},
     {"cpu_freq", psutil_cpu_freq, METH_VARARGS,
      "Return CPU frequency."},
+    {"sensors_battery", psutil_sensors_battery, METH_VARARGS,
+     "Return battery metrics usage."},
 
     // --- windows services
     {"winservice_enumerate", psutil_winservice_enumerate, METH_VARARGS,
diff --git a/psutil/_pswindows.py b/psutil/_pswindows.py
index da8552e1..dd83c929 100644
--- a/psutil/_pswindows.py
+++ b/psutil/_pswindows.py
@@ -361,6 +361,30 @@ def net_if_addrs():
     return ret
 
 
+# =====================================================================
+# --- sensors
+# =====================================================================
+
+
+def sensors_battery():
+    # For constants meaning see:
+    # https://msdn.microsoft.com/en-us/library/windows/desktop/
+    #     aa373232(v=vs.85).aspx
+    acline_status, flags, percent, secsleft = cext.sensors_battery()
+    power_plugged = acline_status == 1
+    no_battery = bool(flags & 128)
+    charging = bool(flags & 8)
+
+    if no_battery:
+        return None
+    if power_plugged or charging:
+        secsleft = _common.POWER_TIME_UNLIMITED
+    elif secsleft == -1:
+        secsleft = _common.POWER_TIME_UNKNOWN
+
+    return _common.sbattery(percent, secsleft, power_plugged)
+
+
 # =====================================================================
 # --- other system functions
 # =====================================================================
diff --git a/psutil/arch/bsd/freebsd.c b/psutil/arch/bsd/freebsd.c
index 456a50aa..0bec81d8 100644
--- a/psutil/arch/bsd/freebsd.c
+++ b/psutil/arch/bsd/freebsd.c
@@ -994,3 +994,27 @@ error:
     PyErr_SetFromErrno(PyExc_OSError);
     return NULL;
 }
+
+
+/*
+ * Return battery information.
+ */
+PyObject *
+psutil_sensors_battery(PyObject *self, PyObject *args) {
+    int percent;
+    int minsleft;
+    int power_plugged;
+    size_t size = sizeof(percent);
+
+    if (sysctlbyname("hw.acpi.battery.life", &percent, &size, NULL, 0))
+        goto error;
+    if (sysctlbyname("hw.acpi.battery.time", &minsleft, &size, NULL, 0))
+        goto error;
+    if (sysctlbyname("hw.acpi.acline", &power_plugged, &size, NULL, 0))
+        goto error;
+    return Py_BuildValue("iii", percent, minsleft, power_plugged);
+
+error:
+    PyErr_SetFromErrno(PyExc_OSError);
+    return NULL;
+}
diff --git a/psutil/arch/bsd/freebsd.h b/psutil/arch/bsd/freebsd.h
index e15706c6..0df66ecc 100644
--- a/psutil/arch/bsd/freebsd.h
+++ b/psutil/arch/bsd/freebsd.h
@@ -27,3 +27,6 @@ PyObject* psutil_proc_threads(PyObject* self, PyObject* args);
 PyObject* psutil_swap_mem(PyObject* self, PyObject* args);
 PyObject* psutil_virtual_mem(PyObject* self, PyObject* args);
 PyObject* psutil_cpu_stats(PyObject* self, PyObject* args);
+#if defined(PSUTIL_FREEBSD)
+PyObject* psutil_sensors_battery(PyObject* self, PyObject* args);
+#endif
diff --git a/scripts/battery.py b/scripts/battery.py
new file mode 100755
index 00000000..eb8b16bb
--- /dev/null
+++ b/scripts/battery.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python
+
+# Copyright (c) 2009, Giampaolo Rodola'. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""
+Show battery information.
+
+$ python battery.py
+charge:     74%
+left:       2:11:31
+status:     discharging
+plugged in: no
+"""
+
+from __future__ import print_function
+import sys
+
+import psutil
+
+
+def secs2hours(secs):
+    mm, ss = divmod(secs, 60)
+    hh, mm = divmod(mm, 60)
+    return "%d:%02d:%02d" % (hh, mm, ss)
+
+
+def main():
+    if not hasattr(psutil, "sensors_battery"):
+        return sys.exit("platform not supported")
+    batt = psutil.sensors_battery()
+    if batt is None:
+        return sys.exit("no battery is installed")
+
+    print("charge:     %s%%" % batt.percent)
+    if batt.power_plugged:
+        print("status:     %s" % (
+            "charging" if batt.percent < 100 else "fully charged"))
+        print("plugged in: yes")
+    else:
+        print("left:       %s" % secs2hours(batt.secsleft))
+        print("status:     %s" % "discharging")
+        print("plugged in: no")
+
+
+if __name__ == '__main__':
+    main()


Test patch:
diff --git a/psutil/tests/test_bsd.py b/psutil/tests/test_bsd.py
index 244672e6..ff46ab33 100755
--- a/psutil/tests/test_bsd.py
+++ b/psutil/tests/test_bsd.py
@@ -357,6 +357,38 @@ class FreeBSDSpecificTestCase(unittest.TestCase):
         btime = int(s)
         self.assertEqual(btime, psutil.boot_time())
 
+    # --- sensors_battery
+
+    @unittest.skipUnless(psutil.sensors_battery(), "no battery")
+    def test_sensors_battery(self):
+        def secs2hours(secs):
+            m, s = divmod(secs, 60)
+            h, m = divmod(m, 60)
+            return "%d:%02d" % (h, m)
+
+        out = sh("acpiconf -i 0")
+        fields = dict([(x.split('\t')[0], x.split('\t')[-1])
+                       for x in out.split("\n")])
+        metrics = psutil.sensors_battery()
+        percent = int(fields['Remaining capacity:'].replace('%', ''))
+        remaining_time = fields['Remaining time:']
+        self.assertEqual(metrics.percent, percent)
+        if remaining_time == 'unknown':
+            self.assertEqual(metrics.secsleft, psutil.POWER_TIME_UNLIMITED)
+        else:
+            self.assertEqual(secs2hours(metrics.secsleft), remaining_time)
+
+    def test_sensors_battery_against_sysctl(self):
+        self.assertEqual(psutil.sensors_battery().percent,
+                         sysctl("hw.acpi.battery.life"))
+        self.assertEqual(psutil.sensors_battery().power_plugged,
+                         sysctl("hw.acpi.acline") == 1)
+        secsleft = psutil.sensors_battery().secsleft
+        if secsleft < 0:
+            self.assertEqual(sysctl("hw.acpi.battery.time"), -1)
+        else:
+            self.assertEqual(secsleft, sysctl("hw.acpi.battery.time") * 60)
+
 
 # =====================================================================
 # --- OpenBSD
diff --git a/psutil/tests/test_linux.py b/psutil/tests/test_linux.py
index 0f0b09c3..f28c70b8 100755
--- a/psutil/tests/test_linux.py
+++ b/psutil/tests/test_linux.py
@@ -1025,6 +1025,73 @@ class TestMisc(unittest.TestCase):
             t.stop()
 
 
+@unittest.skipUnless(LINUX, "LINUX only")
+@unittest.skipUnless(hasattr(psutil, "sensors_battery") and
+                     psutil.sensors_battery() is not None,
+                     "no battery")
+class TestSensorsBattery(unittest.TestCase):
+
+    @unittest.skipUnless(which("acpi"), "acpi utility not available")
+    def test_percent(self):
+        out = sh("acpi -b")
+        acpi_value = int(out.split(",")[1].strip().replace('%', ''))
+        psutil_value = psutil.sensors_battery().percent
+        self.assertAlmostEqual(acpi_value, psutil_value, delta=1)
+
+    @unittest.skipUnless(which("acpi"), "acpi utility not available")
+    def test_power_plugged(self):
+        out = sh("acpi -b")
+        plugged = "Charging" in out.split('\n')[0]
+        self.assertEqual(psutil.sensors_battery().power_plugged, plugged)
+
+    def test_emulate_power_plugged(self):
+        # Pretend the AC power cable is connected.
+        def open_mock(name, *args, **kwargs):
+            if name.startswith("/sys/class/power_supply/AC0/online"):
+                return io.BytesIO(b"1")
+            else:
+                return orig_open(name, *args, **kwargs)
+
+        orig_open = open
+        patch_point = 'builtins.open' if PY3 else '__builtin__.open'
+        with mock.patch(patch_point, side_effect=open_mock) as m:
+            self.assertEqual(psutil.sensors_battery().power_plugged, True)
+            self.assertEqual(
+                psutil.sensors_battery().secsleft, psutil.POWER_TIME_UNLIMITED)
+            assert m.called
+
+    def test_emulate_power_not_plugged(self):
+        # Pretend the AC power cable is not connected.
+        def open_mock(name, *args, **kwargs):
+            if name.startswith("/sys/class/power_supply/AC0/online"):
+                return io.BytesIO(b"0")
+            else:
+                return orig_open(name, *args, **kwargs)
+
+        orig_open = open
+        patch_point = 'builtins.open' if PY3 else '__builtin__.open'
+        with mock.patch(patch_point, side_effect=open_mock) as m:
+            self.assertEqual(psutil.sensors_battery().power_plugged, False)
+            self.assertGreaterEqual(psutil.sensors_battery().secsleft, 0)
+            assert m.called
+
+    def test_emulate_power_undetermined(self):
+        # Pretend we can't know whether the AC power cable not
+        # connected (assert fallback to False).
+        def open_mock(name, *args, **kwargs):
+            if name.startswith("/sys/class/power_supply/AC0/online"):
+                raise IOError(errno.ENOENT, "")
+            else:
+                return orig_open(name, *args, **kwargs)
+
+        orig_open = open
+        patch_point = 'builtins.open' if PY3 else '__builtin__.open'
+        with mock.patch(patch_point, side_effect=open_mock) as m:
+            self.assertEqual(psutil.sensors_battery().power_plugged, False)
+            self.assertGreaterEqual(psutil.sensors_battery().secsleft, 0)
+            assert m.called
+
+
 # =====================================================================
 # test process
 # =====================================================================
diff --git a/psutil/tests/test_memory_leaks.py b/psutil/tests/test_memory_leaks.py
index 44be1ec5..5adb2b5e 100755
--- a/psutil/tests/test_memory_leaks.py
+++ b/psutil/tests/test_memory_leaks.py
@@ -556,6 +556,14 @@ class TestModuleFunctionsLeaks(TestMemLeak):
     def test_net_if_stats(self):
         self.execute(psutil.net_if_stats)
 
+    # --- sensors
+
+    @unittest.skipUnless(hasattr(psutil, "sensors_battery"),
+                         "platform not supported")
+    @skip_if_linux()
+    def test_sensors_battery(self):
+        self.execute(psutil.sensors_battery())
+
     # --- others
 
     @skip_if_linux()
@@ -567,6 +575,12 @@ class TestModuleFunctionsLeaks(TestMemLeak):
     def test_users(self):
         self.execute(psutil.users)
 
+    @unittest.skipUnless(hasattr(psutil, "sensors_temperatures"),
+                         "platform not supported")
+    @skip_if_linux()
+    def test_sensors_temperatures(self):
+        self.execute(psutil.users)
+
     if WINDOWS:
 
         # --- win services
diff --git a/psutil/tests/test_misc.py b/psutil/tests/test_misc.py
index 8697bcff..1d7d71ce 100755
--- a/psutil/tests/test_misc.py
+++ b/psutil/tests/test_misc.py
@@ -469,11 +469,17 @@ class TestScripts(unittest.TestCase):
     def test_cpu_distribution(self):
         self.assert_syntax('cpu_distribution.py')
 
-    def test_sensors(self):
+    def test_temperatures(self):
         if hasattr(psutil, "sensors_temperatures"):
-            self.assert_stdout('sensors.py')
+            self.assert_stdout('temperatures.py')
         else:
-            self.assert_syntax('sensors.py')
+            self.assert_syntax('temperatures.py')
+
+    def test_battery(self):
+        if hasattr(psutil, "sensors_battery"):
+            self.assert_stdout('battery.py')
+        else:
+            self.assert_syntax('battery.py')
 
 
 # ===================================================================
diff --git a/psutil/tests/test_system.py b/psutil/tests/test_system.py
index 79ae6bba..01648380 100755
--- a/psutil/tests/test_system.py
+++ b/psutil/tests/test_system.py
@@ -771,6 +771,22 @@ class TestSystemAPIs(unittest.TestCase):
                 if entry.critical is not None:
                     self.assertGreaterEqual(entry.critical, 0)
 
+    @unittest.skipUnless(LINUX or WINDOWS or FREEBSD,
+                         "platform not supported")
+    def test_sensors_battery(self):
+        ret = psutil.sensors_battery()
+        if ret is None:
+            return  # no battery
+        self.assertGreaterEqual(ret.percent, 0)
+        self.assertLessEqual(ret.percent, 100)
+        if ret.secsleft not in (psutil.POWER_TIME_UNKNOWN,
+                                psutil.POWER_TIME_UNLIMITED):
+            self.assertGreaterEqual(ret.secsleft, 0)
+        else:
+            if ret.secsleft == psutil.POWER_TIME_UNLIMITED:
+                self.assertTrue(ret.power_plugged)
+        self.assertIsInstance(ret.power_plugged, bool)
+
 
 if __name__ == '__main__':
     run_test_module_by_name(__file__)
diff --git a/psutil/tests/test_windows.py b/psutil/tests/test_windows.py
index 07f1d796..aca8afbb 100755
--- a/psutil/tests/test_windows.py
+++ b/psutil/tests/test_windows.py
@@ -158,6 +158,56 @@ class TestSystemAPIs(unittest.TestCase):
                         "no common entries in %s, %s" % (ps_names, wmi_names))
 
 
+# ===================================================================
+# sensors_battery()
+# ===================================================================
+
+
+@unittest.skipUnless(WINDOWS, "WINDOWS only")
+class TestSensorsBattery(unittest.TestCase):
+
+    def test_percent(self):
+        w = wmi.WMI()
+        battery_psutil = psutil.sensors_battery()
+        battery_wmi = w.query('select * from Win32_Battery')[0]
+        if battery_psutil is None:
+            self.assertNot(battery_wmi.EstimatedChargeRemaining)
+            return
+
+        self.assertAlmostEqual(
+            battery_psutil.percent, battery_wmi.EstimatedChargeRemaining,
+            delta=1)
+        self.assertEqual(
+            battery_psutil.power_plugged, battery_wmi.BatteryStatus == 1)
+
+    def test_emulate_no_battery(self):
+        with mock.patch("psutil._pswindows.cext.sensors_battery",
+                        return_value=(0, 128, 0, 0)) as m:
+            self.assertIsNone(psutil.sensors_battery())
+            assert m.called
+
+    def test_emulate_power_connected(self):
+        with mock.patch("psutil._pswindows.cext.sensors_battery",
+                        return_value=(1, 0, 0, 0)) as m:
+            self.assertEqual(psutil.sensors_battery().secsleft,
+                             psutil.POWER_TIME_UNLIMITED)
+            assert m.called
+
+    def test_emulate_power_charging(self):
+        with mock.patch("psutil._pswindows.cext.sensors_battery",
+                        return_value=(0, 8, 0, 0)) as m:
+            self.assertEqual(psutil.sensors_battery().secsleft,
+                             psutil.POWER_TIME_UNLIMITED)
+            assert m.called
+
+    def test_emulate_secs_left_unknown(self):
+        with mock.patch("psutil._pswindows.cext.sensors_battery",
+                        return_value=(0, 0, 0, -1)) as m:
+            self.assertEqual(psutil.sensors_battery().secsleft,
+                             psutil.POWER_TIME_UNKNOWN)
+            assert m.called
+
+
 # ===================================================================
 # Process APIs
 # ===================================================================