diff --git a/doc/configuration.rst b/doc/configuration.rst
index 57b23f13c..96f3238fa 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
@@ -2424,6 +2428,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 6f743d0c7..9393091e3 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 f3bb784e1..88e51268f 100644
--- a/labgrid/driver/power/siglent.py
+++ b/labgrid/driver/power/siglent.py
@@ -1,35 +1,77 @@
-"""tested with Siglent SPD3303X-E, and should be compatible with all SPD3000X series modules"""
+""" tested with Siglent SPD3303X-E, SPD1168X and should be compatible with all SPD3000X series modules """
-import warnings
-
-import vxi11
+import pyvisa
+def _get_psu(host):
+ """Helper to initialize the raw network socket resource session via PyVISA."""
+ rm = pyvisa.ResourceManager("@py")
+ resource_string = f"TCPIP0::{host}::5025::SOCKET"
+ psu = rm.open_resource(resource_string)
+ # Siglent scopes and PSUs require explicit newline termination on raw sockets
+ psu.read_termination = "\n"
+ psu.write_termination = "\n"
+ psu.timeout = 5000 # 5 second safety timeout
+ return psu
def power_set(host, port, index, value):
- warnings.warn(
- "siglent power backend uses vxi11 module using deprecated xdrlib module, see https://github.com/labgrid-project/labgrid/issues/1507",
- DeprecationWarning,
- )
-
assert port is None
index = int(index)
assert 1 <= index <= 2
value = "ON" if value else "OFF"
- psu = vxi11.Instrument(host)
- psu.write(f"OUTPUT CH{index},{value}")
-
+ with _get_psu(host) as psu:
+ psu.write(f"OUTPUT CH{index},{value}")
def power_get(host, port, index):
- warnings.warn(
- "siglent power backend uses vxi11 module using deprecated xdrlib module, see https://github.com/labgrid-project/labgrid/issues/1507",
- DeprecationWarning,
- )
-
assert port is None
index = int(index)
assert 1 <= index <= 2
- psu = vxi11.Instrument(host)
- state = psu.ask("SYSTEM:STATUS?")
+ with _get_psu(host) as psu:
+ state = psu.query("SYSTEM:STATUS?")
state = int(state, 16)
bitmask = 1 << (index + 3)
return bool(state & bitmask)
+
+def power_show(host, port, index):
+ assert port is None
+ index = int(index)
+ assert 1 <= index <= 2
+ 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)
+ assert 1 <= index <= 2
+ with _get_psu(host) as psu:
+ psu.write(f"CH{index}:VOLT {voltage}")
+
+def power_amps(host, port, index, amps):
+ assert port is None
+ index = int(index)
+ with _get_psu(host) as psu:
+ psu.write(f"CH{index}:CURR {amps}")
+
diff --git a/labgrid/driver/powerdriver.py b/labgrid/driver/powerdriver.py
index 45a610097..991ce3091 100644
--- a/labgrid/driver/powerdriver.py
+++ b/labgrid/driver/powerdriver.py
@@ -7,7 +7,7 @@
from ..exceptions import InvalidConfigError
from ..factory import target_factory
-from ..protocol import PowerProtocol, DigitalOutputProtocol, ResetProtocol
+from ..protocol import PowerProtocol, DigitalOutputProtocol, ResetProtocol, ProgrammablePowerProtocol
from ..resource import NetworkPowerPort
from ..step import step
from ..util.proxy import proxymanager
@@ -233,6 +233,21 @@ def cycle(self):
def get(self):
return self.backend.power_get(self._host, self._port, self.port.index)
+ @Driver.check_active
+ @step()
+ def show(self):
+ return self.backend.power_show(self._host, self._port, self.port.index)
+
+ @Driver.check_active
+ @step(args=['voltage'])
+ def voltage(self, voltage):
+ return self.backend.power_voltage(self._host, self._port, self.port.index, voltage)
+
+ @Driver.check_active
+ @step(args=['amps'])
+ def amps(self, amps):
+ return self.backend.power_amps(self._host, self._port, self.port.index, amps)
+
@target_factory.reg_driver
@attr.s(eq=False)
class DigitalOutputPowerDriver(Driver, PowerResetMixin, PowerProtocol):
diff --git a/labgrid/protocol/__init__.py b/labgrid/protocol/__init__.py
index 0ac225622..3b705467a 100644
--- a/labgrid/protocol/__init__.py
+++ b/labgrid/protocol/__init__.py
@@ -2,7 +2,7 @@
from .commandprotocol import CommandProtocol
from .consoleprotocol import ConsoleProtocol
from .linuxbootprotocol import LinuxBootProtocol
-from .powerprotocol import PowerProtocol
+from .powerprotocol import PowerProtocol, ProgrammablePowerProtocol
from .filetransferprotocol import FileTransferProtocol
from .infoprotocol import InfoProtocol
from .digitaloutputprotocol import DigitalOutputProtocol
diff --git a/labgrid/protocol/powerprotocol.py b/labgrid/protocol/powerprotocol.py
index 381be1c6b..8c2b6c0de 100644
--- a/labgrid/protocol/powerprotocol.py
+++ b/labgrid/protocol/powerprotocol.py
@@ -13,3 +13,17 @@ def off(self):
@abc.abstractmethod
def cycle(self):
raise NotImplementedError
+
+
+class ProgrammablePowerProtocol(PowerProtocol):
+ @abc.abstractmethod
+ def show(self, index):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def voltage(self, index, voltage):
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def amps(self, index, amps):
+ raise NotImplementedError
diff --git a/labgrid/remote/client.py b/labgrid/remote/client.py
index 069240b9e..7bac67a3b 100755
--- a/labgrid/remote/client.py
+++ b/labgrid/remote/client.py
@@ -971,9 +971,29 @@ def power(self):
raise UserError("target has no compatible resource available")
if delay is not None:
drv.delay = delay
+
+ if action == "config":
+ if self.args.voltage is None and self.args.amps is None:
+ raise UserError("Option config requires --voltage and/or --amps ")
+
+ if self.args.voltage is not None:
+ drv.voltage(self.args.voltage)
+ print(f"set voltage to {self.args.voltage}V")
+ if self.args.amps is not None:
+ drv.amps(self.args.amps)
+ print(f"set amps to {self.args.amps}A")
+ return
+
res = getattr(drv, action)()
if action == "get":
print(f"power{' ' + name if name else ''} for place {place.name} is {'on' if res else 'off'}")
+ elif action == "show":
+ 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):
place = self.get_acquired_place()
@@ -1982,10 +2002,16 @@ def get_parser(auto_doc_mode=False) -> "argparse.ArgumentParser | AutoProgramArg
subparser.set_defaults(func=ClientSession.print_env)
subparser = subparsers.add_parser("power", aliases=("pw",), help="change (or get) a place's power status")
- subparser.add_argument("action", choices=["on", "off", "cycle", "get"])
+ subparser.add_argument("action", choices=["on", "off", "cycle", "get", "show", "config"])
subparser.add_argument(
"-t", "--delay", type=float, default=None, help="wait time in seconds between off and on during cycle"
)
+ subparser.add_argument(
+ "-v", "--voltage", type=float, default=None, help="voltage value to be configured"
+ )
+ subparser.add_argument(
+ "-a", "--amps", type=float, default=None, help="amps value to be configured"
+ )
subparser.add_argument("--name", "-n", help="optional resource name")
subparser.set_defaults(func=ClientSession.power)
diff --git a/man/labgrid-client.1 b/man/labgrid-client.1
index 45533af05..10d7671b3 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(