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(