Skip to content
Open
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: 15 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -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
Expand Down
77 changes: 77 additions & 0 deletions doc/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
mliberty1 marked this conversation as resolved.
- 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.
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter documentation needs to be adjusted. Are the units here really Hz?
It is worth noting that the delay is used only for PowerProtocol.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, Hz..

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
Expand Down
1 change: 1 addition & 0 deletions labgrid/driver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions labgrid/driver/joulescopedriver.py
Original file line number Diff line number Diff line change
@@ -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

@LinjingZhang LinjingZhang Jul 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After analyzing the output data, I realized that this should actually be in 1MHz.
Could you verify this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is in Hz. The calculation is in util.agents.JoulescopeSession._configure_statistics. What are you seeing that make think otherwise? Note that statistics frequency (provided here) and sample freqeucny (provided to capture) are two different things.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your explanation. I think I got confused between the sample rate frequency (the parameter passed to capture())
and the driver update frequency (defined in the YAML file).

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()
1 change: 1 addition & 0 deletions labgrid/protocol/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions labgrid/protocol/energyanalyzerprotocol.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions labgrid/remote/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions labgrid/resource/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, \
Expand Down
Loading