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(