Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions doc/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,12 @@ Currently available are:
for details.

``siglent``
Controls *Siglent SPD3000X* series modules via the `vxi11 Python module
<https://pypi.org/project/python-vxi11/>`_.
Controls *Siglent SPD3000X* series modules via the `PyVISA Python module
<https://pypi.org/project/PyVISA/>`_ (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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions doc/development.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------

Expand Down
38 changes: 38 additions & 0 deletions doc/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions labgrid/driver/power/siglent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions labgrid/remote/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
13 changes: 12 additions & 1 deletion man/labgrid-client.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -571,6 +572,16 @@ wait time in seconds between off and on during cycle
.UNINDENT
.INDENT 0.0
.TP
.B \-v <voltage>, \-\-voltage <voltage>
voltage value to be configured
.UNINDENT
.INDENT 0.0
.TP
.B \-a <amps>, \-\-amps <amps>
amps value to be configured
.UNINDENT
.INDENT 0.0
.TP
.B \-\-name <name>, \-n <name>
optional resource name
.UNINDENT
Expand Down
74 changes: 73 additions & 1 deletion tests/test_powerdriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down