diff --git a/doc/configuration.rst b/doc/configuration.rst
index cb7e4c520..5affd6b64 100644
--- a/doc/configuration.rst
+++ b/doc/configuration.rst
@@ -247,8 +247,12 @@ Currently available are:
for details.
``siglent``
- Controls *Siglent SPD3000X* series modules via the `vxi11 Python module
- `_.
+ Controls *Siglent SPD3000X* series modules via the `PyVISA Python module
+ `_ (with the ``pyvisa-py`` backend).
+ In addition to switching the output on and off, this backend supports
+ reading and configuring the per-channel voltage and current limits and
+ reading the measured voltage, current and power (see the ``show`` and
+ ``config`` actions of ``labgrid-client power``).
``simplerest``
This is a generic backend for PDU implementations which can be controlled via
@@ -2418,6 +2422,13 @@ Implements:
Arguments:
- delay (float, default=2.0): delay in seconds between off and on
+Some programmable power supplies (currently the ``siglent`` backend) additionally
+implement :any:`ProgrammablePowerProtocol`, which allows reading the measured
+voltage, current and power and configuring the voltage and current limits. These
+are exposed via the ``show`` and ``config`` actions of ``labgrid-client power``
+(see :ref:`usage-power` for details). Backends that do not implement this
+protocol simply do not provide those actions.
+
PDUDaemonDriver
~~~~~~~~~~~~~~~
A :any:`PDUDaemonDriver` controls a `PDUDaemonPort`_, allowing control of the
diff --git a/doc/development.rst b/doc/development.rst
index 4283912dc..356e96025 100644
--- a/doc/development.rst
+++ b/doc/development.rst
@@ -160,6 +160,24 @@ The minimum requirement is a call to :code:`super().__attrs_post_init__()`.
All that's left now is to implement the functionality described by the used
protocol, by using the API of the bound drivers and resources.
+Programmable Power Supplies
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Simple power drivers only implement :any:`PowerProtocol` (``on``, ``off``,
+``cycle`` and ``get``). Power supplies that can additionally report and
+configure their output implement :any:`ProgrammablePowerProtocol`, which
+extends :any:`PowerProtocol` with three methods:
+
+- ``show()`` returns a dict with the measured ``voltage``, ``amps`` and
+ ``watts`` as well as the configured ``v_limit`` and ``a_limit``.
+- ``voltage(value)`` sets the output voltage.
+- ``amps(value)`` sets the output current limit.
+
+For the :any:`NetworkPowerDriver`, these are dispatched to the configured
+backend module, which has to provide matching ``power_show``,
+``power_voltage`` and ``power_amps`` functions (see
+``labgrid/driver/power/siglent.py`` for a reference implementation).
+
Writing a Resource
-------------------
diff --git a/doc/usage.rst b/doc/usage.rst
index 37527fb40..e94903d8e 100644
--- a/doc/usage.rst
+++ b/doc/usage.rst
@@ -166,6 +166,44 @@ allocated before returning.
A reservation will time out after a short time, if it is neither refreshed nor
used by locked places.
+.. _usage-power:
+
+Controlling Power
+~~~~~~~~~~~~~~~~~
+
+The ``labgrid-client power`` command (short alias ``pw``) switches a place's
+power on and off:
+
+.. code-block:: bash
+
+ $ labgrid-client -p my-place power on
+ $ labgrid-client -p my-place power off
+ $ labgrid-client -p my-place power cycle
+ $ labgrid-client -p my-place power get
+
+For programmable power supplies (currently the ``siglent`` backend of
+:any:`NetworkPowerPort`), two additional actions are available.
+
+``power config`` sets the voltage and/or current limit:
+
+.. code-block:: bash
+
+ $ labgrid-client -p my-place power config --voltage 5.0 --amps 1.5
+ set voltage to 5.0V
+ set amps to 1.5A
+
+``power show`` reads back the measured voltage, current and power together with
+the configured limits (shown in square brackets):
+
+.. code-block:: bash
+
+ $ labgrid-client -p my-place power show
+ Voltage: 5.001V [5.0V], Current: 0.209A [1.5A], Power: 1.045W
+
+The power value is read directly from the device rather than being computed from
+voltage times current, so it stays accurate even though those two values are
+sampled at slightly different times.
+
Library
-------
labgrid can be used directly as a Python library, without the infrastructure
diff --git a/labgrid/driver/power/siglent.py b/labgrid/driver/power/siglent.py
index 467eeb389..88e51268f 100644
--- a/labgrid/driver/power/siglent.py
+++ b/labgrid/driver/power/siglent.py
@@ -38,15 +38,30 @@ def power_show(host, port, index):
with _get_psu(host) as psu:
v_measured = psu.query(f"MEAS:VOLT? CH{index}")
a_measured = psu.query(f"MEAS:CURR? CH{index}")
+ w_measured = psu.query(f"MEAS:POWE? CH{index}")
v_set = psu.query(f"CH{index}:VOLT?")
a_set = psu.query(f"CH{index}:CURR?")
return {
"voltage": float(v_measured),
"amps": float(a_measured),
+ "watts": float(w_measured),
"v_limit": float(v_set),
"a_limit": float(a_set)
}
+def power_watts(host, port, index):
+ """Read the power (in watts) measured by the device on the given channel.
+
+ The PSU reports true power directly, so this avoids errors from
+ multiplying voltage and current sampled at slightly different times.
+ """
+ assert port is None
+ index = int(index)
+ assert 1 <= index <= 2
+ with _get_psu(host) as psu:
+ w_measured = psu.query(f"MEAS:POWE? CH{index}")
+ return float(w_measured)
+
def power_voltage(host, port, index, voltage):
assert port is None
index = int(index)
diff --git a/labgrid/remote/client.py b/labgrid/remote/client.py
index 60342864a..280b48178 100755
--- a/labgrid/remote/client.py
+++ b/labgrid/remote/client.py
@@ -941,7 +941,6 @@ def power(self):
from ..resource.power import NetworkPowerPort, PDUDaemonPort
from ..resource.remote import NetworkUSBPowerPort, NetworkSiSPMPowerPort, NetworkSysfsGPIO
from ..resource import TasmotaPowerPort, NetworkYKUSHPowerPort
- from ..protocol import ProgrammablePowerProtocol
drv = None
try:
@@ -989,8 +988,11 @@ def power(self):
if action == "get":
print(f"power{' ' + name if name else ''} for place {place.name} is {'on' if res else 'off'}")
elif action == "show":
- res = drv.show()
- print(f"Voltage: {res['voltage']}V [{res['v_limit']}V], Current: {res['amps']}A [{res['a_limit']}A]")
+ print(
+ f"Voltage: {res['voltage']}V [{res['v_limit']}V], "
+ f"Current: {res['amps']}A [{res['a_limit']}A], "
+ f"Power: {res['watts']}W"
+ )
def digital_io(self):
diff --git a/man/labgrid-client.1 b/man/labgrid-client.1
index dfc95b2e8..ff4add310 100644
--- a/man/labgrid-client.1
+++ b/man/labgrid-client.1
@@ -556,7 +556,8 @@ change (or get) a place\(aqs power status
.INDENT 3.5
.sp
.EX
-usage: labgrid\-client power|pw [\-t DELAY] [\-\-name NAME] {on,off,cycle,get}
+usage: labgrid\-client power|pw [\-t DELAY] [\-v VOLTAGE] [\-a AMPS] [\-\-name NAME]
+ {on,off,cycle,get,show,config}
.EE
.UNINDENT
.UNINDENT
@@ -571,6 +572,16 @@ wait time in seconds between off and on during cycle
.UNINDENT
.INDENT 0.0
.TP
+.B \-v , \-\-voltage
+voltage value to be configured
+.UNINDENT
+.INDENT 0.0
+.TP
+.B \-a , \-\-amps
+amps value to be configured
+.UNINDENT
+.INDENT 0.0
+.TP
.B \-\-name , \-n
optional resource name
.UNINDENT
diff --git a/tests/test_powerdriver.py b/tests/test_powerdriver.py
index 8926333c6..3486fdd04 100644
--- a/tests/test_powerdriver.py
+++ b/tests/test_powerdriver.py
@@ -310,13 +310,85 @@ def test_import_backend_tplink(self):
import labgrid.driver.power.tplink
def test_import_backend_siglent(self):
- pytest.importorskip("vxi11")
+ pytest.importorskip("pyvisa")
import labgrid.driver.power.siglent
def test_import_backend_poe_mib(self):
pytest.importorskip("pysnmp")
import labgrid.driver.power.poe_mib
+ def _mock_siglent_psu(self, mocker, responses=None):
+ rm = mocker.patch("pyvisa.ResourceManager")
+ psu = rm.return_value.open_resource.return_value
+ # `with _get_psu(...) as psu` must yield the same mock we configure
+ psu.__enter__.return_value = psu
+ if responses is not None:
+ psu.query.side_effect = lambda cmd: responses[cmd]
+ return psu
+
+ def _activate_siglent_driver(self, target):
+ NetworkPowerPort(target, "power", model="siglent", host="192.0.2.1", index="1")
+ d = NetworkPowerDriver(target, "power")
+ target.activate(d)
+ return d
+
+ def test_siglent_on_off_get(self, target, mocker):
+ pytest.importorskip("pyvisa")
+
+ # CH1 output state is reported in bit 4 of the hex SYSTEM:STATUS? value
+ psu = self._mock_siglent_psu(mocker, {"SYSTEM:STATUS?": "0x0010"})
+ d = self._activate_siglent_driver(target)
+
+ d.on()
+ psu.write.assert_called_with("OUTPUT CH1,ON")
+ d.off()
+ psu.write.assert_called_with("OUTPUT CH1,OFF")
+
+ assert d.get() is True
+ psu.query.side_effect = lambda cmd: {"SYSTEM:STATUS?": "0x0000"}[cmd]
+ assert d.get() is False
+
+ def test_siglent_show(self, target, mocker):
+ pytest.importorskip("pyvisa")
+
+ self._mock_siglent_psu(mocker, {
+ "MEAS:VOLT? CH1": "5.001",
+ "MEAS:CURR? CH1": "0.209",
+ "MEAS:POWE? CH1": "1.045",
+ "CH1:VOLT?": "5.0",
+ "CH1:CURR?": "1.5",
+ })
+ d = self._activate_siglent_driver(target)
+
+ assert d.show() == {
+ "voltage": 5.001,
+ "amps": 0.209,
+ "watts": 1.045,
+ "v_limit": 5.0,
+ "a_limit": 1.5,
+ }
+
+ def test_siglent_voltage_amps(self, target, mocker):
+ pytest.importorskip("pyvisa")
+
+ psu = self._mock_siglent_psu(mocker)
+ d = self._activate_siglent_driver(target)
+
+ d.voltage(5.0)
+ psu.write.assert_called_with("CH1:VOLT 5.0")
+ d.amps(1.5)
+ psu.write.assert_called_with("CH1:CURR 1.5")
+
+ def test_siglent_power_watts(self, mocker):
+ pytest.importorskip("pyvisa")
+ from labgrid.driver.power import siglent
+
+ psu = self._mock_siglent_psu(mocker, {"MEAS:POWE? CH2": "2.5"})
+
+ # power is read from the device, not derived from voltage * current
+ assert siglent.power_watts("192.0.2.1", None, 2) == 2.5
+ psu.query.assert_called_once_with("MEAS:POWE? CH2")
+
class TestYKUSHPowerDriver:
YKUSH_FAKE_SERIAL = "YK12345"
YKUSH_LIST_OUTPUT = f"Attached YKUSH Boards:\n1. Board found with serial number: {YKUSH_FAKE_SERIAL}".encode(