From efe6826b68a6042fe60576436f5c34fcd1a1d1e9 Mon Sep 17 00:00:00 2001 From: Lothar Rubusch Date: Sat, 4 Jul 2026 19:11:22 +0200 Subject: [PATCH 1/4] driver: power: siglent: migrate driver to pyvisa The siglent power backend uses the vxi11 module. It hasn't seen any activity in the last 7 years. It still uses the deprecated xdrlib module from the Python standard library. xdrlib will be removed in Python 3.13 Replace the vxi11 usage by pyvisa. Pyvisa already seems to be used in the labgrid project. Thus this reduces a further external dependency. Fixes: https://github.com/labgrid-project/labgrid/issues/1507 Signed-off-by: Lothar Rubusch Assisted-by: Gemini-2.5-Flash --- labgrid/driver/power/siglent.py | 35 +++++++++++++++------------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/labgrid/driver/power/siglent.py b/labgrid/driver/power/siglent.py index f3bb784e1..227388450 100644 --- a/labgrid/driver/power/siglent.py +++ b/labgrid/driver/power/siglent.py @@ -1,35 +1,32 @@ -"""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) From ee9e9b8bb5f23da55bd7f93902aae6bb4796fb3b Mon Sep 17 00:00:00 2001 From: Lothar Rubusch Date: Sat, 4 Jul 2026 19:16:55 +0200 Subject: [PATCH 2/4] driver: power: add show and config options Generally add setting and checking basic power settings such as voltage and current. This comes as an option, i.e. it can be implemented for more elaborate power supplies. This is verified with some Siglent devices. Usage example: $ labgrid-client power config --voltage 5.0 --amps 1.5 Selected role main from configuration file set voltage to 5.0V set amps to 1.5A $ labgrid-client power show Selected role main from configuration file Voltage: 0.0V [5.0V], Current: 0.0A [1.5A] $ labgrid-client power on Selected role main from configuration file $ labgrid-client power show Selected role main from configuration file Voltage: 5.001V [5.0V], Current: 0.209A [1.5A] Signed-off-by: Lothar Rubusch Assisted-by: Gemini-2.5-Flash --- labgrid/driver/power/siglent.py | 30 ++++++++++++++++++++++++++++++ labgrid/driver/powerdriver.py | 17 ++++++++++++++++- labgrid/protocol/__init__.py | 2 +- labgrid/protocol/powerprotocol.py | 14 ++++++++++++++ labgrid/remote/client.py | 26 +++++++++++++++++++++++++- 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/labgrid/driver/power/siglent.py b/labgrid/driver/power/siglent.py index 227388450..467eeb389 100644 --- a/labgrid/driver/power/siglent.py +++ b/labgrid/driver/power/siglent.py @@ -30,3 +30,33 @@ def power_get(host, port, index): 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}") + 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), + "v_limit": float(v_set), + "a_limit": float(a_set) + } + +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 4d2eb0bfa..60342864a 100755 --- a/labgrid/remote/client.py +++ b/labgrid/remote/client.py @@ -941,6 +941,7 @@ 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: @@ -971,9 +972,26 @@ 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": + res = drv.show() + print(f"Voltage: {res['voltage']}V [{res['v_limit']}V], Current: {res['amps']}A [{res['a_limit']}A]") + def digital_io(self): place = self.get_acquired_place() @@ -1982,10 +2000,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) From af5b19849772044272a1cf189a4695cefee243a8 Mon Sep 17 00:00:00 2001 From: oip Date: Mon, 13 Jul 2026 16:56:19 +0200 Subject: [PATCH 3/4] Driver: power: read and report measured power Read the device-measured power (watts) from the Sigilent backend via MEAS:POWE?, exposed both as new power_watts() helper and as an extra field of power_show(). The 'labgrid-client power show' output now also prints the power value. This work was sponsored by OIP Sensor Systems Signed-off-by: oip --- labgrid/driver/power/siglent.py | 15 +++++++++++++++ labgrid/remote/client.py | 8 +++++--- 2 files changed, 20 insertions(+), 3 deletions(-) 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): From 6b23014469ec3c610d3e800e44a476a4eeb5bd59 Mon Sep 17 00:00:00 2001 From: oip Date: Mon, 13 Jul 2026 17:00:57 +0200 Subject: [PATCH 4/4] driver: power: siglent: add tests and documentation Add tests for the Siglent power backend (on/off/get, show including the measured power, and voltage/current configuration) and document the power show/config actions in the configuration, usage and development guides. Regenerate the labgrid-client man page for the new power sub-actions and options. This work was sponsored by OIP Sensor Systems Signed-off-by: oip --- doc/configuration.rst | 15 ++++++-- doc/development.rst | 18 ++++++++++ doc/usage.rst | 38 ++++++++++++++++++++ man/labgrid-client.1 | 13 ++++++- tests/test_powerdriver.py | 74 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 154 insertions(+), 4 deletions(-) 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/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(