diff --git a/CHANGES.rst b/CHANGES.rst index d068962c8..69cba0649 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,18 @@ +Release 27.0 (Unreleased) +------------------------- + +New Features in 27.0 +~~~~~~~~~~~~~~~~~~~~~~ + +- Support for the Joulescope energy analyzer was added via the new + ``JoulescopeDriver`` and ``JoulescopeDevice``, which implement the new + ``EnergyAnalyzerProtocol`` (statistics, charge/energy accumulation and + high-rate sample capture) as well as the ``PowerProtocol``. Install the + optional dependency with ``pip install labgrid[joulescope]``. A Joulescope can + be shared over the distributed infrastructure: exporting a ``JoulescopeDevice`` + makes it available to clients as a ``NetworkJoulescopeDevice``, with + ``pyjoulescope_driver`` running on the exporting host via the labgrid agent. + Release 26.0 (Released Jun 06, 2026) ------------------------------------ Sponsored by: Analog Devices GmbH diff --git a/doc/configuration.rst b/doc/configuration.rst index 489b1255f..766627843 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -765,6 +765,39 @@ Arguments: Used by: - `SigrokDriver`_ +JoulescopeDevice +~~~~~~~~~~~~~~~~ +A :any:`JoulescopeDevice` resource describes a *Joulescope* energy analyzer +(JS110, JS220 or JS320). It is a USB resource, so a specific device is selected +via udev matching when more than one Joulescope is connected; with a single +Joulescope an empty match is sufficient. The device is then addressed through +``pyjoulescope_driver``. + +.. code-block:: yaml + + JoulescopeDevice: + match: + ID_SERIAL_SHORT: 'S3C8' + +You need to ensure proper udev permissions: + +.. code-block:: bash + + wget https://raw.githubusercontent.com/jetperch/joulescope_driver/refs/heads/main/72-joulescope.rules + sudo cp 72-joulescope.rules /etc/udev/rules.d/ + sudo udevadm control --reload-rules + +Arguments: + - match (dict): key and value pairs for a udev match, see `udev Matching`_ + +Used by: + - `JoulescopeDriver`_ + +NetworkJoulescopeDevice +~~~~~~~~~~~~~~~~~~~~~~~~~ +A :any:`NetworkJoulescopeDevice` resource describes a `JoulescopeDevice`_ resource +available on a remote computer. + IMXUSBLoader ~~~~~~~~~~~~ An :any:`IMXUSBLoader` resource describes a USB device in the imx loader state. @@ -3191,6 +3224,50 @@ samples is an iterable of samples. This driver relies on buffering of the subprocess call. Reading a few samples will very likely work - but obtaining a lot of samples may stall. +JoulescopeDriver +~~~~~~~~~~~~~~~~ +The :any:`JoulescopeDriver` uses a `JoulescopeDevice`_ or +`NetworkJoulescopeDevice`_ resource to measure current, voltage and power, +accumulate charge and energy, capture high-rate samples to a JLS file, and switch +downstream power by connecting/disconnecting the device current path. + +Binds to: + device: + - `JoulescopeDevice`_ + - `NetworkJoulescopeDevice`_ + +Implements: + - :any:`EnergyAnalyzerProtocol` + - :any:`PowerProtocol` + +.. code-block:: yaml + + JoulescopeDriver: + frequency: 10.0 + delay: 2.0 + +Arguments: + - frequency (float, default=2.0): statistics update frequency in Hz + - delay (float, default=2.0): delay in seconds between off and on during a + power cycle + +The latest measurement is read with ``get_statistics()``, which returns a dict +with ``current``, ``voltage`` and ``power`` sub-dicts (each with ``avg``, +``std``, ``min`` and ``max``) plus the accumulated ``charge_C`` (Coulombs) and +``energy_J`` (Joules). +``start()`` and ``stop()`` bracket a charge/energy accumulation window; +``stop()`` returns the accumulated ``energy_J``, ``charge_C`` and the +``duration_s`` of the window. +``capture(filename, signals=None, duration=..., frequency=None)`` records +high-rate samples to a JLS file for the requested duration. ``frequency`` (in +Hz) sets the device sample rate and is sticky: once set it stays in effect for +later captures on the same activated driver until changed again, rather than +reverting to the device default. + +Power switching via ``on()``, ``off()`` and ``cycle()`` connects and +disconnects the device current path, controlling downstream power to the +device under test. + USBSDMuxDriver ~~~~~~~~~~~~~~ The :any:`USBSDMuxDriver` uses a `USBSDMuxDevice`_ resource to control a diff --git a/labgrid/driver/__init__.py b/labgrid/driver/__init__.py index d3cd6f55e..c889d76bc 100644 --- a/labgrid/driver/__init__.py +++ b/labgrid/driver/__init__.py @@ -25,6 +25,7 @@ from .modbusdriver import ModbusCoilDriver, WaveShareModbusCoilDriver from .modbusrtudriver import ModbusRTUDriver from .sigrokdriver import SigrokDriver, SigrokPowerDriver, SigrokDmmDriver +from .joulescopedriver import JoulescopeDriver from .usbstoragedriver import USBStorageDriver, Mode from .resetdriver import DigitalOutputResetDriver from .gpiodriver import GpioDigitalOutputDriver diff --git a/labgrid/driver/joulescopedriver.py b/labgrid/driver/joulescopedriver.py new file mode 100644 index 000000000..9b95ed77d --- /dev/null +++ b/labgrid/driver/joulescopedriver.py @@ -0,0 +1,130 @@ +import os +import time +import uuid + +import attr + +from ..factory import target_factory +from ..protocol import EnergyAnalyzerProtocol, PowerProtocol +from ..resource.joulescope import JoulescopeDevice +from ..resource.remote import NetworkJoulescopeDevice +from ..step import step +from ..util.agentwrapper import AgentWrapper +from ..util.ssh import sshmanager +from .common import Driver + + +@target_factory.reg_driver +@attr.s(eq=False) +class JoulescopeDriver(Driver, EnergyAnalyzerProtocol, PowerProtocol): + """The JoulescopeDriver controls a Joulescope energy analyzer. + + It wraps ``pyjoulescope_driver`` to stream measurement statistics + (current, voltage, power and accumulated charge/energy), to capture + high-rate samples to a JLS file, and to connect/disconnect the device + current path (downstream power) as a :class:`PowerProtocol` power switch. + + ``pyjoulescope_driver`` runs on the host the Joulescope is attached to + through labgrid's agent mechanism, so the same driver works for a locally + attached device and for one shared over the distributed infrastructure via + a :class:`~labgrid.resource.remote.NetworkJoulescopeDevice`. Only the host + with the device attached needs the ``joulescope`` extra installed. + + Power switching (``on``/``off``/``cycle``) controls downstream power to the + device under test: the JS110 uses the current range ``select`` and the JS220 + and JS320 use the current range ``mode``. + + Args: + frequency (float): statistics update frequency in Hz + delay (float): delay between off and on during a power cycle + """ + + bindings = {"device": {JoulescopeDevice, NetworkJoulescopeDevice}} + frequency = attr.ib(default=2.0, validator=attr.validators.instance_of(float)) + delay = attr.ib(default=2.0, validator=attr.validators.instance_of(float)) + + def __attrs_post_init__(self): + super().__attrs_post_init__() + self.wrapper = None + self.proxy = None + + # -- life cycle --------------------------------------------------------- + + def on_activate(self): + host = self.device.host if isinstance(self.device, NetworkJoulescopeDevice) else None + self.wrapper = AgentWrapper(host) + try: + self.proxy = self.wrapper.load("joulescope") + self.proxy.open(self.device.serial, self.device.model, self.frequency) + except Exception: + # on_deactivate() only runs once the driver is active, so clean up + # the (possibly remote) agent subprocess if opening the device fails. + self.wrapper.close() + self.wrapper = None + self.proxy = None + raise + + def on_deactivate(self): + try: + self.proxy.close(self.device.serial, self.device.model) + finally: + self.wrapper.close() + self.wrapper = None + self.proxy = None + + # -- statistics --------------------------------------------------------- + + @Driver.check_active + @step(result=True) + def get_statistics(self): + return self.proxy.get_statistics(self.device.serial, self.device.model) + + @Driver.check_active + @step() + def start(self): + self.proxy.start(self.device.serial, self.device.model) + + @Driver.check_active + @step(result=True) + def stop(self): + return self.proxy.stop(self.device.serial, self.device.model) + + # -- sample capture ----------------------------------------------------- + + @Driver.check_active + @step(args=["filename", "duration"]) + def capture(self, filename, signals=None, duration=None, frequency=None): + if duration is None: + raise ValueError("capture() requires a duration in seconds") + if isinstance(self.device, NetworkJoulescopeDevice): + # Record on the host the device is attached to, then copy the JLS + # file back to the client and remove the remote copy. + remote = f"/tmp/labgrid-joulescope-{uuid.uuid4()}.jls" + self.proxy.capture(self.device.serial, self.device.model, remote, signals, duration, frequency) + try: + sshmanager.get_file(self.device.host, remote, filename) + finally: + self.proxy.remove(remote) + else: + self.proxy.capture( + self.device.serial, self.device.model, os.fspath(filename), signals, duration, frequency + ) + + # -- power switch (PowerProtocol) -------------------------------------- + + @Driver.check_active + @step() + def on(self): + self.proxy.set_power(self.device.serial, self.device.model, True) + + @Driver.check_active + @step() + def off(self): + self.proxy.set_power(self.device.serial, self.device.model, False) + + @Driver.check_active + @step() + def cycle(self): + self.off() + time.sleep(self.delay) + self.on() diff --git a/labgrid/protocol/__init__.py b/labgrid/protocol/__init__.py index 0ac225622..c1cb6a5c9 100644 --- a/labgrid/protocol/__init__.py +++ b/labgrid/protocol/__init__.py @@ -3,6 +3,7 @@ from .consoleprotocol import ConsoleProtocol from .linuxbootprotocol import LinuxBootProtocol from .powerprotocol import PowerProtocol +from .energyanalyzerprotocol import EnergyAnalyzerProtocol from .filetransferprotocol import FileTransferProtocol from .infoprotocol import InfoProtocol from .digitaloutputprotocol import DigitalOutputProtocol diff --git a/labgrid/protocol/energyanalyzerprotocol.py b/labgrid/protocol/energyanalyzerprotocol.py new file mode 100644 index 000000000..d51a6c823 --- /dev/null +++ b/labgrid/protocol/energyanalyzerprotocol.py @@ -0,0 +1,48 @@ +import abc + + +class EnergyAnalyzerProtocol(abc.ABC): + """Protocol for energy analyzers such as the Joulescope. + + An energy analyzer continuously measures current, voltage and power and + accumulates charge and energy. Drivers implementing this protocol expose + the latest statistics, an accumulation window (start/stop) for charge and + energy, and high-rate sample capture to a file. + """ + + @abc.abstractmethod + def get_statistics(self): + """Return the latest measurement statistics as a dict. + + The returned dict contains ``current``, ``voltage`` and ``power`` + sub-dicts (each with ``avg``, ``std``, ``min`` and ``max`` keys) as + well as the accumulated ``charge_C`` (Coulombs) and ``energy_J`` + (Joules). Convenience values such as average current are read from + this return value rather than via dedicated accessors. + """ + raise NotImplementedError + + @abc.abstractmethod + def start(self): + """Begin a charge/energy accumulation window.""" + raise NotImplementedError + + @abc.abstractmethod + def stop(self): + """End the accumulation window started by :meth:`start`. + + Returns a dict with the accumulated ``energy_J`` (Joules), + ``charge_C`` (Coulombs) and the ``duration_s`` (seconds) of the window. + """ + raise NotImplementedError + + @abc.abstractmethod + def capture(self, filename, signals=None, duration=None, frequency=None): + """Capture high-rate samples to a file for the given duration. + + ``frequency`` (in Hz) sets the device sample rate for the capture. It + is sticky: once set it remains in effect for subsequent captures on the + same activated driver until changed again, rather than reverting to the + device default. + """ + raise NotImplementedError diff --git a/labgrid/remote/exporter.py b/labgrid/remote/exporter.py index 68c4fa708..1d6faa6d6 100755 --- a/labgrid/remote/exporter.py +++ b/labgrid/remote/exporter.py @@ -386,6 +386,34 @@ def _get_params(self): } +@attr.s(eq=False) +class JoulescopeExport(USBGenericExport): + """ResourceExport for Joulescope energy analyzers""" + + def __attrs_post_init__(self): + # USBGenericExport imports the local class from resource.udev, but the + # JoulescopeDevice lives in resource.joulescope, so build it here (the + # ProviderGenericExport does the same for its own module). + ResourceExport.__attrs_post_init__(self) + self.data["cls"] = f"Network{self.cls}" + from ..resource.joulescope import JoulescopeDevice + + self.local = JoulescopeDevice(target=None, name=None, **self.local_params) + + def _get_params(self): + """Helper function to return parameters""" + return { + "host": self.host, + "busnum": self.local.busnum, + "devnum": self.local.devnum, + "path": self.local.path, + "vendor_id": self.local.vendor_id, + "model_id": self.local.model_id, + "serial": self.local.serial, + "model": self.local.model, + } + + @attr.s(eq=False) class USBSDMuxExport(USBGenericExport): """ResourceExport for USB devices accessed directly from userspace""" @@ -578,6 +606,7 @@ def __attrs_post_init__(self): exports["AlteraUSBBlaster"] = USBGenericExport exports["SigrokUSBDevice"] = USBSigrokExport exports["SigrokUSBSerialDevice"] = USBSigrokExport +exports["JoulescopeDevice"] = JoulescopeExport exports["USBSDMuxDevice"] = USBSDMuxExport exports["USBSDWireDevice"] = USBSDWireExport exports["USBSDWire3Device"] = USBSDWire3Export diff --git a/labgrid/resource/__init__.py b/labgrid/resource/__init__.py index 53d88f458..4dfbc603d 100644 --- a/labgrid/resource/__init__.py +++ b/labgrid/resource/__init__.py @@ -47,6 +47,7 @@ from .dediprogflasher import DediprogFlasher, NetworkDediprogFlasher from .httpdigitalout import HttpDigitalOutput from .sigrok import SigrokDevice +from .joulescope import JoulescopeDevice from .fastboot import AndroidNetFastboot from .eth008 import Eth008DigitalOutput from .laa import LAASerialPort, LAAPowerPort, LAAUSBGadgetMassStorage, \ diff --git a/labgrid/resource/joulescope.py b/labgrid/resource/joulescope.py new file mode 100644 index 000000000..f5a872727 --- /dev/null +++ b/labgrid/resource/joulescope.py @@ -0,0 +1,49 @@ +import attr + +from ..factory import target_factory +from .udev import USBResource + +# Joulescope USB product ids (application mode) mapped to their model name. The +# vendor id ``16d0`` is shared with other manufacturers, so the product id is +# what actually identifies a Joulescope. Bootloader-mode product ids (``0e87``, +# ``10b9``, ``1359``) are intentionally excluded: they cannot measure. +JOULESCOPE_MODELS = { + "0e88": "js110", + "10ba": "js220", + "135a": "js320", +} + + +@target_factory.reg_resource +@attr.s(eq=False) +class JoulescopeDevice(USBResource): + """The JoulescopeDevice describes a Joulescope energy analyzer. + + It is a :class:`USBResource`, so the usual udev ``match`` mechanism selects a + specific device (for example by ``ID_SERIAL_SHORT`` or ``ID_PATH``) when more + than one Joulescope is connected. By default it matches any Joulescope + (JS110, JS220 or JS320). The :class:`~labgrid.driver.JoulescopeDriver` + addresses the matched device through ``pyjoulescope_driver`` using the + :attr:`serial` and :attr:`model` derived below. + """ + + def __attrs_post_init__(self): + self.match["ID_VENDOR_ID"] = "16d0" + super().__attrs_post_init__() + + def filter_match(self, device): + return device.properties.get("ID_MODEL_ID") in JOULESCOPE_MODELS + + @property + def serial(self): + """The device serial number, identical to the pyjoulescope_driver path serial.""" + if self.device is not None: + return self.device.properties.get("ID_SERIAL_SHORT") + return None + + @property + def model(self): + """The device model, e.g. ``js320``, derived from the USB product id.""" + if self.device is not None: + return JOULESCOPE_MODELS.get(self.device.properties.get("ID_MODEL_ID")) + return None diff --git a/labgrid/resource/remote.py b/labgrid/resource/remote.py index d2a63d5e9..900048052 100644 --- a/labgrid/resource/remote.py +++ b/labgrid/resource/remote.py @@ -216,6 +216,23 @@ def __attrs_post_init__(self): super().__attrs_post_init__() +@target_factory.reg_resource +@attr.s(eq=False) +class NetworkJoulescopeDevice(RemoteUSBResource): + """The NetworkJoulescopeDevice describes a remotely accessible Joulescope""" + serial = attr.ib( + default=None, + validator=attr.validators.optional(attr.validators.instance_of(str)) + ) + model = attr.ib( + default=None, + validator=attr.validators.optional(attr.validators.instance_of(str)) + ) + def __attrs_post_init__(self): + self.timeout = 10.0 + super().__attrs_post_init__() + + @target_factory.reg_resource @attr.s(eq=False) class NetworkUSBMassStorage(RemoteUSBResource): diff --git a/labgrid/util/agents/joulescope.py b/labgrid/util/agents/joulescope.py new file mode 100644 index 000000000..fe10a43fe --- /dev/null +++ b/labgrid/util/agents/joulescope.py @@ -0,0 +1,234 @@ +""" +This module runs pyjoulescope_driver on the host the Joulescope is physically +connected to, so the device can be shared over labgrid's distributed +infrastructure. The JoulescopeDriver talks to it through the AgentWrapper, both +for local devices (agent runs as a local subprocess) and for remote devices +(agent runs on the exporter over SSH). + +Supported functionality: + +- stream measurement statistics (current, voltage, power, charge, energy) +- accumulate charge/energy over a start()/stop() window +- capture high-rate samples to a JLS file +- switch downstream power (PowerProtocol) + +Only stdlib and pyjoulescope_driver are used so the module stays self-contained +when copied to the exporter. +""" + +import contextlib +import os +import time + +import pyjoulescope_driver + + +class JoulescopeSession: + """Encapsulates a single opened Joulescope and its statistics stream.""" + + def __init__(self, serial, model, frequency): + self._pyjsdrv = pyjoulescope_driver + self.serial = serial + self.model = model + self.frequency = frequency + self._latest = None + self._accum = None + # pyjoulescope_driver matches subscriptions by callback identity, so the + # exact same bound method must be passed to subscribe and unsubscribe. + self._stats_cb = self._on_statistics + self._jsdrv = self._pyjsdrv.Driver() + self._path = self._resolve_path(self._jsdrv.device_paths()) + # ``model`` may have been None (match any); derive it from the path. + self._model = self._path.split("/")[1] + self._jsdrv.open(self._path) + self._configure_statistics() + + # -- life cycle --------------------------------------------------------- + + def _resolve_path(self, paths): + serial, model = self.serial, self.model + matches = [p for p in paths if (serial is None or serial in p) and (model is None or f"/{model}/" in p)] + if not matches: + raise RuntimeError(f"No Joulescope matching serial={serial!r} model={model!r} found in {paths}") + if len(matches) > 1: + raise RuntimeError(f"Multiple Joulescopes match serial={serial!r} model={model!r}: {matches}") + return matches[0] + + def close(self): + try: + self._jsdrv.unsubscribe(self._path + "/s/stats/value", self._stats_cb) + self._jsdrv.publish(self._path + "/s/stats/ctrl", 0) + self._jsdrv.close(self._path) + finally: + self._jsdrv.finalize() + + # -- statistics --------------------------------------------------------- + + def _configure_statistics(self): + dev = self._path + if self._model == "js110": + self._jsdrv.publish(dev + "/s/i/range/select", "auto") + # host-side statistics honor the requested frequency and report std + self._jsdrv.publish(dev + "/s/i/ctrl", "on") + self._jsdrv.publish(dev + "/s/v/ctrl", "on") + self._jsdrv.publish(dev + "/s/p/ctrl", "on") + base = 2_000_000 # JS110 host-side statistics sample rate + else: # js220, js320, ... + self._jsdrv.publish(dev + "/s/i/range/mode", "auto") + base = 1_000_000 # JS220/JS320 sensor-side statistics sample rate + # The statistics update every scnt samples counted at the device's fixed + # sample rate (``base``). That rate is not reliably queryable across + # models (``h/fs`` reads back ``None`` on the JS220/JS320), so use the + # documented per-model value, matching pyjoulescope_driver's own + # statistics example. + scnt = max(1, int(round(base / self.frequency))) + self._jsdrv.publish(dev + "/s/stats/scnt", scnt) + self._jsdrv.publish(dev + "/s/stats/ctrl", 1) + self._jsdrv.subscribe(dev + "/s/stats/value", "pub", self._stats_cb) + + def _on_statistics(self, topic, value): + self._latest = value + + def _wait_for_statistics(self): + """Block until a fresh statistics value arrives and return it.""" + self._latest = None + deadline = time.monotonic() + max(2.0, 4.0 / self.frequency) + while self._latest is None: + if time.monotonic() > deadline: + raise RuntimeError("timed out waiting for Joulescope statistics") + time.sleep(0.01) + return self._latest + + @staticmethod + def _parse_statistics(value): + signals = value["signals"] + + def signal(name): + s = signals[name] + return {k: (s[k]["value"] if k in s else None) for k in ("avg", "std", "min", "max")} + + return { + "current": signal("current"), + "voltage": signal("voltage"), + "power": signal("power"), + "charge_C": value["accumulators"]["charge"]["value"], + "energy_J": value["accumulators"]["energy"]["value"], + "time": { + "utc": value["time"]["utc"]["value"], + "samples": value["time"]["samples"]["value"], + }, + } + + def get_statistics(self): + return self._parse_statistics(self._wait_for_statistics()) + + def start(self): + value = self._wait_for_statistics() + self._accum = { + "charge": value["accumulators"]["charge"]["value"], + "energy": value["accumulators"]["energy"]["value"], + "utc": value["time"]["utc"]["value"][1], + } + + def stop(self): + if self._accum is None: + raise RuntimeError("stop() called without a preceding start()") + value = self._wait_for_statistics() + time64 = self._pyjsdrv.time64 + utc_end = value["time"]["utc"]["value"][1] + result = { + "energy_J": value["accumulators"]["energy"]["value"] - self._accum["energy"], + "charge_C": value["accumulators"]["charge"]["value"] - self._accum["charge"], + "duration_s": time64.as_timestamp(utc_end) - time64.as_timestamp(self._accum["utc"]), + } + self._accum = None + return result + + # -- sample capture ----------------------------------------------------- + + def capture(self, filename, signals=None, duration=None, frequency=None): + if duration is None: + raise ValueError("capture() requires a duration in seconds") + if frequency is not None: + # Note: this changes the device sample rate for the rest of the + # session; it is not restored to the default after the capture. + self._jsdrv.publish(self._path + "/h/fs", int(frequency)) + recorder = self._pyjsdrv.Record(self._jsdrv, self._path, signals=signals or ["current", "voltage", "power"]) + recorder.open(filename) + try: + deadline = time.monotonic() + float(duration) + while time.monotonic() < deadline: + time.sleep(0.05) + finally: + recorder.close() + return filename + + # -- power switch ------------------------------------------------------- + + def set_power(self, enabled): + dev = self._path + value = "auto" if enabled else "off" + # JS110 switches downstream power via the current range "select"; the + # JS220 and JS320 use the current range "mode". + topic = "/s/i/range/select" if self._model == "js110" else "/s/i/range/mode" + self._jsdrv.publish(dev + topic, value) + + +_sessions = {} + + +def _key(serial, model): + return f"{serial}/{model}" + + +def handle_open(serial, model, frequency): + key = _key(serial, model) + if key not in _sessions: + _sessions[key] = JoulescopeSession(serial, model, frequency) + # If a session for this device already exists it is reused as-is; the + # frequency passed here is ignored (the existing session keeps its own). + # handle_close() pops the session, so a fresh open() always reconfigures. + return True + + +def handle_close(serial, model): + session = _sessions.pop(_key(serial, model), None) + if session is not None: + session.close() + + +def handle_get_statistics(serial, model): + return _sessions[_key(serial, model)].get_statistics() + + +def handle_start(serial, model): + _sessions[_key(serial, model)].start() + + +def handle_stop(serial, model): + return _sessions[_key(serial, model)].stop() + + +def handle_capture(serial, model, filename, signals=None, duration=None, frequency=None): + return _sessions[_key(serial, model)].capture(filename, signals, duration, frequency) + + +def handle_set_power(serial, model, enabled): + _sessions[_key(serial, model)].set_power(enabled) + + +def handle_remove(filename): + with contextlib.suppress(FileNotFoundError): + os.remove(filename) + + +methods = { + "open": handle_open, + "close": handle_close, + "get_statistics": handle_get_statistics, + "start": handle_start, + "stop": handle_stop, + "capture": handle_capture, + "set_power": handle_set_power, + "remove": handle_remove, +} diff --git a/pyproject.toml b/pyproject.toml index a046ebe65..754f4247d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,10 @@ doc = [ ] docker = ["docker>=5.0.2"] graph = ["graphviz>=0.17.0"] +joulescope = [ + "pyjoulescope_driver>=1.9.0", + "pyjls>=0.11.0", +] kasa = ["python-kasa>=0.7.0"] laa = ["laam"] modbus = ["pyModbusTCP>=0.2.0"] @@ -222,6 +226,7 @@ include = [ "**/pyproject.toml", "examples/**/*.py", "labgrid/driver/httpvideodriver.py", + "labgrid/driver/joulescopedriver.py", "labgrid/driver/manualswitchdriver.py", "labgrid/driver/power/gude8031.py", "labgrid/driver/power/pe6216.py", @@ -231,7 +236,9 @@ include = [ "labgrid/protocol/**/*.py", "labgrid/remote/**/*.py", "labgrid/resource/httpvideostream.py", + "labgrid/resource/joulescope.py", "labgrid/resource/provider.py", + "labgrid/util/agents/joulescope.py", "labgrid/util/agents/network_interface.py", "labgrid/util/agents/usb_hid_relay.py", "labgrid/util/exceptions.py", diff --git a/tests/conftest.py b/tests/conftest.py index 44f9ca76f..fdcf963b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -251,6 +251,8 @@ def pytest_addoption(parser): help="Run SSHManager tests against localhost") parser.addoption("--ssh-username", default=None, help="SSH username to use for SSHDriver testing") + parser.addoption("--joulescope", action="store_true", + help="Run Joulescope tests against a connected device (16d0:*)") def pytest_configure(config): # register an additional marker @@ -262,6 +264,8 @@ def pytest_configure(config): "sshusername: test SSHDriver against Localhost") config.addinivalue_line("markers", "coordinator: test against local coordinator") + config.addinivalue_line("markers", + "joulescope: enable tests against a connected Joulescope") def pytest_runtest_setup(item): envmarker = item.get_closest_marker("sigrokusb") @@ -276,3 +280,7 @@ def pytest_runtest_setup(item): if envmarker is not None: if item.config.getoption("--ssh-username") is None: pytest.skip("SSHDriver tests against localhost not enabled (enable with --ssh-username )") + envmarker = item.get_closest_marker("joulescope") + if envmarker is not None: + if item.config.getoption("--joulescope") is False: + pytest.skip("Joulescope tests not enabled (enable with --joulescope)") diff --git a/tests/test_joulescope_agent.py b/tests/test_joulescope_agent.py new file mode 100644 index 000000000..5f9025fd4 --- /dev/null +++ b/tests/test_joulescope_agent.py @@ -0,0 +1,198 @@ +"""Unit tests for the Joulescope agent module (labgrid/util/agents/joulescope.py). + +The agent holds all pyjoulescope_driver interaction and runs on the host the +device is attached to. A fake ``pyjoulescope_driver`` is injected via +``sys.modules`` so these tests run without the real package or hardware. +""" + +import importlib +import sys + +import pytest + + +def make_sample(charge=0.0, energy=0.0, utc=(0, 1 << 30)): + """Build a statistics value mimicking pyjoulescope_driver's s/stats/value.""" + + def sig(avg, std, lo, hi): + return { + "avg": {"value": avg}, + "std": {"value": std}, + "min": {"value": lo}, + "max": {"value": hi}, + } + + return { + "signals": { + "current": sig(1.0, 0.1, 0.5, 1.5), + "voltage": sig(3.3, 0.01, 3.2, 3.4), + "power": sig(3.3, 0.1, 1.6, 5.0), + }, + "accumulators": { + "charge": {"value": charge}, + "energy": {"value": energy}, + }, + "time": { + "utc": {"value": list(utc)}, + "samples": {"value": [0, 1000]}, + }, + } + + +@pytest.fixture +def agent(mocker): + fake = mocker.MagicMock(name="pyjoulescope_driver") + jsdrv = mocker.MagicMock(name="Driver") + fake.Driver.return_value = jsdrv + jsdrv.device_paths.return_value = ["u/js220/001234"] + fake.time64.as_timestamp.side_effect = lambda t: t / float(1 << 30) + + mocker.patch.dict(sys.modules, {"pyjoulescope_driver": fake}) + mod = importlib.import_module("labgrid.util.agents.joulescope") + mod = importlib.reload(mod) # rebind module global to the fake + mod._sessions.clear() + + state = {"cb": None, "samples": []} + + def subscribe(topic, flags, fn): + state["cb"] = fn + + jsdrv.subscribe.side_effect = subscribe + + def sleep(_duration): + # deliver the next queued sample through the stored subscription callback, + # mirroring how the device thread feeds _wait_for_statistics() + if state["samples"] and state["cb"] is not None: + path = jsdrv.device_paths.return_value[0] + state["cb"](path + "/s/stats/value", state["samples"].pop(0)) + + mocker.patch.object(mod.time, "sleep", side_effect=sleep) + + fake._jsdrv = jsdrv + fake._state = state + return mod, fake + + +def make_session(agent, serial="001234", model="js220", frequency=2.0): + mod, _ = agent + return mod.JoulescopeSession(serial, model, frequency) + + +def test_open_configures_and_subscribes(agent): + _, fake = agent + make_session(agent) + fake._jsdrv.open.assert_called_once_with("u/js220/001234") + fake._jsdrv.publish.assert_any_call("u/js220/001234/s/stats/ctrl", 1) + fake._jsdrv.subscribe.assert_called_once() + + +def test_resolve_no_match(agent): + with pytest.raises(RuntimeError): + make_session(agent, serial="999999") + + +def test_resolve_multiple(agent): + _, fake = agent + fake._jsdrv.device_paths.return_value = ["u/js220/001234", "u/js220/005678"] + with pytest.raises(RuntimeError): + make_session(agent, serial=None) + + +def test_get_statistics(agent): + _, fake = agent + s = make_session(agent) + fake._state["samples"] = [make_sample(charge=2.0, energy=5.0)] + stats = s.get_statistics() + assert stats["current"]["avg"] == 1.0 + assert stats["voltage"]["avg"] == 3.3 + assert stats["power"]["max"] == 5.0 + assert stats["charge_C"] == 2.0 + assert stats["energy_J"] == 5.0 + + +def test_start_stop_accumulation(agent): + _, fake = agent + s = make_session(agent) + fake._state["samples"] = [ + make_sample(charge=1.0, energy=2.0, utc=(0, 0)), + make_sample(charge=4.0, energy=10.0, utc=(0, 1 << 30)), # +1 second + ] + s.start() + result = s.stop() + assert result["charge_C"] == pytest.approx(3.0) + assert result["energy_J"] == pytest.approx(8.0) + assert result["duration_s"] == pytest.approx(1.0) + + +def test_stop_without_start(agent): + _, fake = agent + s = make_session(agent) + fake._state["samples"] = [make_sample()] + with pytest.raises(RuntimeError): + s.stop() + + +def test_capture(agent, mocker): + _, fake = agent + s = make_session(agent) + recorder = mocker.MagicMock(name="Record") + fake.Record.return_value = recorder + result = s.capture("out.jls", signals=["current", "power"], duration=0) + fake.Record.assert_called_once_with(fake._jsdrv, "u/js220/001234", signals=["current", "power"]) + recorder.open.assert_called_once_with("out.jls") + recorder.close.assert_called_once() + assert result == "out.jls" + + +def test_capture_requires_duration(agent): + s = make_session(agent) + with pytest.raises(ValueError): + s.capture("out.jls") + + +def test_power_js110(agent): + _, fake = agent + fake._jsdrv.device_paths.return_value = ["u/js110/000111"] + s = make_session(agent, serial="000111", model="js110") + s.set_power(True) + fake._jsdrv.publish.assert_any_call("u/js110/000111/s/i/range/select", "auto") + s.set_power(False) + fake._jsdrv.publish.assert_any_call("u/js110/000111/s/i/range/select", "off") + + +def test_power_js220(agent): + _, fake = agent + s = make_session(agent) + s.set_power(False) + fake._jsdrv.publish.assert_any_call("u/js220/001234/s/i/range/mode", "off") + s.set_power(True) + fake._jsdrv.publish.assert_any_call("u/js220/001234/s/i/range/mode", "auto") + + +def test_power_js320(agent): + _, fake = agent + fake._jsdrv.device_paths.return_value = ["u/js320/8w2a"] + s = make_session(agent, serial="8w2a", model="js320") + s.set_power(False) + fake._jsdrv.publish.assert_any_call("u/js320/8w2a/s/i/range/mode", "off") + + +def test_close(agent): + _, fake = agent + s = make_session(agent) + s.close() + fake._jsdrv.close.assert_called_once_with("u/js220/001234") + fake._jsdrv.finalize.assert_called_once() + + +def test_handlers_open_get_close(agent): + mod, fake = agent + fake._state["samples"] = [make_sample(charge=2.0, energy=5.0)] + assert mod.handle_open("001234", "js220", 2.0) is True + # opening again reuses the existing session + mod.handle_open("001234", "js220", 2.0) + fake.Driver.assert_called_once() + stats = mod.handle_get_statistics("001234", "js220") + assert stats["charge_C"] == 2.0 + mod.handle_close("001234", "js220") + fake._jsdrv.close.assert_called_once_with("u/js220/001234") diff --git a/tests/test_joulescope_hw.py b/tests/test_joulescope_hw.py new file mode 100644 index 000000000..6eed8c754 --- /dev/null +++ b/tests/test_joulescope_hw.py @@ -0,0 +1,74 @@ +"""Hardware-in-the-loop tests for the Joulescope driver. + +These talk to a real Joulescope (JS110/JS220/JS320) over USB and are skipped +unless ``--joulescope`` is passed. They live in their own module so they use +the real udev ``ManagedResource`` machinery instead of the mocks in +``test_joulescopedriver.py``. +""" + +import pytest + +from labgrid import Target +from labgrid.driver.joulescopedriver import JoulescopeDriver +from labgrid.resource.joulescope import JOULESCOPE_MODELS, JoulescopeDevice + +pytestmark = pytest.mark.joulescope + + +@pytest.fixture +def driver(): + t = Target("js") + dev = JoulescopeDevice(t, "dev") + d = JoulescopeDriver(t, "jsdrv", frequency=10.0) + t.activate(d) + yield dev, d + t.deactivate(d) + + +def test_resource_matched(driver): + dev, _ = driver + assert dev.avail + assert dev.model in JOULESCOPE_MODELS.values() + assert dev.serial + + +def test_get_statistics(driver): + _, d = driver + stats = d.get_statistics() + for signal in ("current", "voltage", "power"): + assert set(stats[signal]) == {"avg", "std", "min", "max"} + assert isinstance(stats[signal]["avg"], float) + assert isinstance(stats["charge_C"], float) + assert isinstance(stats["energy_J"], float) + + +def test_start_stop_window(driver): + import time + + _, d = driver + d.start() + time.sleep(1.0) + window = d.stop() + assert set(window) == {"energy_J", "charge_C", "duration_s"} + # the window is quantized to the statistics period (0.1 s at 10 Hz) + assert 0.9 < window["duration_s"] < 1.3 + + +def test_capture(driver, tmp_path): + _, d = driver + filename = str(tmp_path / "capture.jls") + d.capture(filename, duration=0.5) + + pyjls = pytest.importorskip("pyjls") + with pyjls.Reader(filename) as r: + names = [s.name for s in r.signals.values()] + for signal in ("current", "voltage", "power"): + assert signal in names + + +def test_power_switch(driver): + _, d = driver + # exercises the JS220/JS320 s/i/range/mode (or JS110 s/i/range/select) path + d.off() + d.on() + d.cycle() diff --git a/tests/test_joulescopedriver.py b/tests/test_joulescopedriver.py new file mode 100644 index 000000000..962391e05 --- /dev/null +++ b/tests/test_joulescopedriver.py @@ -0,0 +1,199 @@ +import pytest + +from labgrid.driver.joulescopedriver import JoulescopeDriver +from labgrid.resource.common import ResourceManager +from labgrid.resource.joulescope import JOULESCOPE_MODELS, JoulescopeDevice +from labgrid.resource.remote import NetworkJoulescopeDevice + +_MODEL_PID = {model: pid for pid, model in JOULESCOPE_MODELS.items()} + + +class FakeUdevDevice: + """Minimal stand-in for a pyudev device exposing ``properties.get``.""" + + def __init__(self, properties): + self.properties = properties + + +@pytest.fixture(autouse=True) +def no_managers(mocker): + """Use the inert base ResourceManager so resources never touch real udev or a coordinator.""" + mocker.patch.object(JoulescopeDevice, "manager_cls", ResourceManager) + mocker.patch.object(NetworkJoulescopeDevice, "manager_cls", ResourceManager) + + +@pytest.fixture +def fake_agent(mocker): + """Patch AgentWrapper so the driver talks to a fake agent proxy. + + ``wrapper`` is the AgentWrapper instance, ``proxy`` the loaded module proxy. + ``AgentWrapper`` records the host it was constructed with in ``wrapper._host``. + """ + proxy = mocker.MagicMock(name="proxy") + wrapper = mocker.MagicMock(name="wrapper") + wrapper.load.return_value = proxy + + def factory(host=None): + wrapper._host = host + return wrapper + + cls = mocker.patch("labgrid.driver.joulescopedriver.AgentWrapper", side_effect=factory) + cls.wrapper = wrapper + cls.proxy = proxy + return cls + + +def make_device(target, serial="001234", model="js220"): + """Create a local JoulescopeDevice with udev properties faked as if matched.""" + properties = {} + if serial is not None: + properties["ID_SERIAL_SHORT"] = serial + if model is not None: + properties["ID_MODEL_ID"] = _MODEL_PID[model] + dev = JoulescopeDevice(target, "js") + dev.device = FakeUdevDevice(properties) + dev.avail = True + return dev + + +def make_network_device(target, host="exporter", serial="001234", model="js220"): + """Create a NetworkJoulescopeDevice as the coordinator would hand to a client.""" + dev = NetworkJoulescopeDevice( + target, + "js", + host=host, + busnum=None, + devnum=None, + path=None, + vendor_id=None, + model_id=None, + serial=serial, + model=model, + ) + dev.avail = True + return dev + + +def make_driver(target, fake_agent, network=False, **kwargs): + if network: + make_network_device(target) + else: + make_device(target) + d = JoulescopeDriver(target, "jsdrv", **kwargs) + target.activate(d) + return d + + +def test_create(target, fake_agent): + make_device(target) + d = JoulescopeDriver(target, "jsdrv") + assert isinstance(d, JoulescopeDriver) + + +def test_binds_local_and_network(target, fake_agent): + # both resource types are accepted by the driver bindings + assert JoulescopeDevice in JoulescopeDriver.bindings["device"] + assert NetworkJoulescopeDevice in JoulescopeDriver.bindings["device"] + + +def test_activate_local_uses_local_agent(target, fake_agent): + make_driver(target, fake_agent) + # local device -> agent runs locally (host None) + assert fake_agent.wrapper._host is None + fake_agent.wrapper.load.assert_called_once_with("joulescope") + fake_agent.proxy.open.assert_called_once_with("001234", "js220", 2.0) + + +def test_activate_network_uses_remote_agent(target, fake_agent): + make_driver(target, fake_agent, network=True) + # network device -> agent runs on the exporter host + assert fake_agent.wrapper._host == "exporter" + fake_agent.proxy.open.assert_called_once_with("001234", "js220", 2.0) + + +def test_activate_open_failure_closes_wrapper(target, fake_agent): + make_device(target) + fake_agent.proxy.open.side_effect = RuntimeError("no device") + d = JoulescopeDriver(target, "jsdrv") + with pytest.raises(RuntimeError): + target.activate(d) + # opening failed, so the agent subprocess must be cleaned up here since + # on_deactivate() would not run for a driver that never became active + fake_agent.wrapper.close.assert_called_once() + assert d.wrapper is None + assert d.proxy is None + + +def test_activate_passes_frequency(target, fake_agent): + make_device(target) + d = JoulescopeDriver(target, "jsdrv", frequency=10.0) + target.activate(d) + fake_agent.proxy.open.assert_called_once_with("001234", "js220", 10.0) + + +def test_get_statistics_delegates(target, fake_agent): + d = make_driver(target, fake_agent) + fake_agent.proxy.get_statistics.return_value = {"power": {"avg": 3.3}} + assert d.get_statistics() == {"power": {"avg": 3.3}} + fake_agent.proxy.get_statistics.assert_called_once_with("001234", "js220") + + +def test_start_stop_delegate(target, fake_agent): + d = make_driver(target, fake_agent) + fake_agent.proxy.stop.return_value = {"energy_J": 8.0, "charge_C": 3.0, "duration_s": 1.0} + d.start() + result = d.stop() + fake_agent.proxy.start.assert_called_once_with("001234", "js220") + fake_agent.proxy.stop.assert_called_once_with("001234", "js220") + assert result["energy_J"] == 8.0 + + +def test_power_on_off_delegate(target, fake_agent): + d = make_driver(target, fake_agent) + d.on() + fake_agent.proxy.set_power.assert_any_call("001234", "js220", True) + d.off() + fake_agent.proxy.set_power.assert_any_call("001234", "js220", False) + + +def test_power_cycle(target, fake_agent, mocker): + d = make_driver(target, fake_agent, delay=0.0) + mocker.patch("labgrid.driver.joulescopedriver.time.sleep") + d.cycle() + calls = [c.args for c in fake_agent.proxy.set_power.call_args_list] + assert ("001234", "js220", False) in calls + assert ("001234", "js220", True) in calls + + +def test_capture_local(target, fake_agent): + d = make_driver(target, fake_agent) + d.capture("out.jls", signals=["current", "power"], duration=1.0) + # local: the agent writes the file directly, no copy-back + fake_agent.proxy.capture.assert_called_once_with("001234", "js220", "out.jls", ["current", "power"], 1.0, None) + fake_agent.proxy.remove.assert_not_called() + + +def test_capture_remote_copies_back(target, fake_agent, mocker): + d = make_driver(target, fake_agent, network=True) + get_file = mocker.patch("labgrid.driver.joulescopedriver.sshmanager.get_file") + d.capture("local.jls", duration=1.0) + # remote: written to a temp path on the exporter, copied back, then removed + remote = fake_agent.proxy.capture.call_args.args[2] + assert remote.startswith("/tmp/labgrid-joulescope-") + assert remote.endswith(".jls") + get_file.assert_called_once_with("exporter", remote, "local.jls") + fake_agent.proxy.remove.assert_called_once_with(remote) + + +def test_capture_requires_duration(target, fake_agent): + d = make_driver(target, fake_agent) + with pytest.raises(ValueError): + d.capture("out.jls") + fake_agent.proxy.capture.assert_not_called() + + +def test_deactivate_closes_proxy_and_wrapper(target, fake_agent): + d = make_driver(target, fake_agent) + target.deactivate(d) + fake_agent.proxy.close.assert_called_once_with("001234", "js220") + fake_agent.wrapper.close.assert_called_once()