Skip to content

Commit 5172db5

Browse files
mliberty1claude
andcommitted
Add Joulescope energy analyzer driver and EnergyAnalyzerProtocol
Add support for the Joulescope energy analyzer (JS110/JS220/JS320) via pyjoulescope_driver: - New EnergyAnalyzerProtocol (get_statistics/start/stop/capture) - New JoulescopeDevice resource: a USBResource selected via udev matching; serial and model are derived from udev (ID_SERIAL_SHORT / ID_MODEL_ID) - New JoulescopeDriver implementing EnergyAnalyzerProtocol and PowerProtocol (downstream power switch: JS110 via s/i/range/select, JS220/JS320 via s/i/range/mode). Statistics use the documented per-model base sample rate (h/fs reads back None on JS220/JS320). - Remote/distributed support: exporting a JoulescopeDevice makes it available to clients as a NetworkJoulescopeDevice. pyjoulescope_driver runs on the exporting host through the labgrid agent (util/agents/joulescope.py), so the driver binds to both the local and network resource and works unchanged over the coordinator/exporter infrastructure; captures are recorded on the exporting host and copied back. - Optional dependency extra labgrid[joulescope] - Unit tests (mocked) for the driver and the agent, plus a hardware-in-the-loop suite gated behind --joulescope, configuration docs and changelog entry Verified hardware-in-the-loop against a JS320, both locally and via a remote coordinator/exporter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Matt Liberty <matt.liberty@jetperch.com>
1 parent 63749cf commit 5172db5

16 files changed

Lines changed: 1109 additions & 0 deletions

CHANGES.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
Release 27.0 (Unreleased)
2+
-------------------------
3+
4+
New Features in 27.0
5+
~~~~~~~~~~~~~~~~~~~~~~
6+
7+
- Support for the Joulescope energy analyzer was added via the new
8+
``JoulescopeDriver`` and ``JoulescopeDevice``, which implement the new
9+
``EnergyAnalyzerProtocol`` (statistics, charge/energy accumulation and
10+
high-rate sample capture) as well as the ``PowerProtocol``. Install the
11+
optional dependency with ``pip install labgrid[joulescope]``. A Joulescope can
12+
be shared over the distributed infrastructure: exporting a ``JoulescopeDevice``
13+
makes it available to clients as a ``NetworkJoulescopeDevice``, with
14+
``pyjoulescope_driver`` running on the exporting host via the labgrid agent.
15+
116
Release 26.0 (Released Jun 06, 2026)
217
------------------------------------
318
Sponsored by: Analog Devices GmbH

doc/configuration.rst

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,55 @@ Arguments:
765765
Used by:
766766
- `SigrokDriver`_
767767

768+
JoulescopeDevice
769+
~~~~~~~~~~~~~~~~
770+
A :any:`JoulescopeDevice` resource describes a *Joulescope* energy analyzer
771+
(JS110, JS220 or JS320). It is a USB resource, so a specific device is selected
772+
via udev matching when more than one Joulescope is connected; with a single
773+
Joulescope an empty match is sufficient. The device is then addressed through
774+
``pyjoulescope_driver``.
775+
776+
.. code-block:: yaml
777+
778+
JoulescopeDevice:
779+
match:
780+
ID_SERIAL_SHORT: 'S3C8'
781+
782+
Arguments:
783+
- match (dict): key and value pairs for a udev match, see `udev Matching`_
784+
785+
Used by:
786+
- `JoulescopeDriver`_
787+
788+
NetworkJoulescopeDevice
789+
~~~~~~~~~~~~~~~~~~~~~~~~~
790+
A :any:`NetworkJoulescopeDevice` resource describes a `JoulescopeDevice`_ that is
791+
attached to and exported by another host, making it usable over labgrid's
792+
distributed infrastructure. It is created automatically when a `JoulescopeDevice`_
793+
is exported and acquired via a `RemotePlace`_, so it is not usually configured
794+
directly. The `JoulescopeDriver`_ runs ``pyjoulescope_driver`` on the exporting
795+
host through the labgrid agent, so only that host needs the ``joulescope`` extra
796+
installed.
797+
798+
.. note::
799+
The labgrid agent is started on the exporting host over SSH as
800+
``python3 <agent>``, so the ``python3`` found on that host's *non-interactive*
801+
SSH ``PATH`` must be able to import ``pyjoulescope_driver`` (and ``pyjls`` for
802+
high-rate sample capture). Installing the
803+
``joulescope`` extra into a virtualenv that is only activated for an
804+
interactive shell is not sufficient; install it into the interpreter on the
805+
default ``PATH`` (or make that virtualenv's ``python3`` the default). This is
806+
the same requirement as for other agent-based USB devices (for example the
807+
``pyusb`` dependency of the HID and Deditec relays).
808+
809+
Arguments:
810+
- host (str): hostname of the exporter the device is attached to
811+
- serial (str): the Joulescope serial number
812+
- model (str): the Joulescope model (``js110``, ``js220`` or ``js320``)
813+
814+
Used by:
815+
- `JoulescopeDriver`_
816+
768817
IMXUSBLoader
769818
~~~~~~~~~~~~
770819
An :any:`IMXUSBLoader` resource describes a USB device in the imx loader state.
@@ -3191,6 +3240,55 @@ samples is an iterable of samples.
31913240
This driver relies on buffering of the subprocess call.
31923241
Reading a few samples will very likely work - but obtaining a lot of samples may stall.
31933242

3243+
JoulescopeDriver
3244+
~~~~~~~~~~~~~~~~
3245+
The :any:`JoulescopeDriver` uses a `JoulescopeDevice`_ or
3246+
`NetworkJoulescopeDevice`_ resource to measure current, voltage and power,
3247+
accumulate charge and energy, capture high-rate samples to a JLS file, and switch
3248+
downstream power by connecting/disconnecting the device current path.
3249+
3250+
``pyjoulescope_driver`` runs on the host the Joulescope is attached to (via the
3251+
labgrid agent), so only that host needs the ``joulescope`` extra installed. For a
3252+
`NetworkJoulescopeDevice`_ a captured JLS file is recorded on the exporting host
3253+
and copied back to the client.
3254+
3255+
Binds to:
3256+
device:
3257+
- `JoulescopeDevice`_
3258+
- `NetworkJoulescopeDevice`_
3259+
3260+
Implements:
3261+
- :any:`EnergyAnalyzerProtocol`
3262+
- :any:`PowerProtocol`
3263+
3264+
.. code-block:: yaml
3265+
3266+
JoulescopeDriver:
3267+
frequency: 10.0
3268+
delay: 2.0
3269+
3270+
Arguments:
3271+
- frequency (float, default=2.0): statistics update frequency in Hz
3272+
- delay (float, default=2.0): delay in seconds between off and on during a
3273+
power cycle
3274+
3275+
The latest measurement is read with ``get_statistics()``, which returns a dict
3276+
with ``current``, ``voltage`` and ``power`` sub-dicts (each with ``avg``,
3277+
``std``, ``min`` and ``max``) plus the accumulated ``charge_C`` (Coulombs) and
3278+
``energy_J`` (Joules).
3279+
``start()`` and ``stop()`` bracket a charge/energy accumulation window;
3280+
``stop()`` returns the accumulated ``energy_J``, ``charge_C`` and the
3281+
``duration_s`` of the window.
3282+
``capture(filename, signals=None, duration=..., frequency=None)`` records
3283+
high-rate samples to a JLS file for the requested duration. ``frequency`` (in
3284+
Hz) sets the device sample rate and is sticky: once set it stays in effect for
3285+
later captures on the same activated driver until changed again, rather than
3286+
reverting to the device default.
3287+
3288+
Power switching via ``on()``, ``off()`` and ``cycle()`` connects and
3289+
disconnects the device current path, controlling downstream power to the
3290+
device under test.
3291+
31943292
USBSDMuxDriver
31953293
~~~~~~~~~~~~~~
31963294
The :any:`USBSDMuxDriver` uses a `USBSDMuxDevice`_ resource to control a

labgrid/driver/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .modbusdriver import ModbusCoilDriver, WaveShareModbusCoilDriver
2626
from .modbusrtudriver import ModbusRTUDriver
2727
from .sigrokdriver import SigrokDriver, SigrokPowerDriver, SigrokDmmDriver
28+
from .joulescopedriver import JoulescopeDriver
2829
from .usbstoragedriver import USBStorageDriver, Mode
2930
from .resetdriver import DigitalOutputResetDriver
3031
from .gpiodriver import GpioDigitalOutputDriver

labgrid/driver/joulescopedriver.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import os
2+
import time
3+
import uuid
4+
5+
import attr
6+
7+
from ..factory import target_factory
8+
from ..protocol import EnergyAnalyzerProtocol, PowerProtocol
9+
from ..resource.joulescope import JoulescopeDevice
10+
from ..resource.remote import NetworkJoulescopeDevice
11+
from ..step import step
12+
from ..util.agentwrapper import AgentWrapper
13+
from ..util.ssh import sshmanager
14+
from .common import Driver
15+
16+
17+
@target_factory.reg_driver
18+
@attr.s(eq=False)
19+
class JoulescopeDriver(Driver, EnergyAnalyzerProtocol, PowerProtocol):
20+
"""The JoulescopeDriver controls a Joulescope energy analyzer.
21+
22+
It wraps ``pyjoulescope_driver`` to stream measurement statistics
23+
(current, voltage, power and accumulated charge/energy), to capture
24+
high-rate samples to a JLS file, and to connect/disconnect the device
25+
current path (downstream power) as a :class:`PowerProtocol` power switch.
26+
27+
``pyjoulescope_driver`` runs on the host the Joulescope is attached to
28+
through labgrid's agent mechanism, so the same driver works for a locally
29+
attached device and for one shared over the distributed infrastructure via
30+
a :class:`~labgrid.resource.remote.NetworkJoulescopeDevice`. Only the host
31+
with the device attached needs the ``joulescope`` extra installed.
32+
33+
Power switching (``on``/``off``/``cycle``) controls downstream power to the
34+
device under test: the JS110 uses the current range ``select`` and the JS220
35+
and JS320 use the current range ``mode``.
36+
37+
Args:
38+
frequency (float): statistics update frequency in Hz
39+
delay (float): delay between off and on during a power cycle
40+
"""
41+
42+
bindings = {"device": {JoulescopeDevice, NetworkJoulescopeDevice}}
43+
frequency = attr.ib(default=2.0, validator=attr.validators.instance_of(float))
44+
delay = attr.ib(default=2.0, validator=attr.validators.instance_of(float))
45+
46+
def __attrs_post_init__(self):
47+
super().__attrs_post_init__()
48+
self.wrapper = None
49+
self.proxy = None
50+
51+
# -- life cycle ---------------------------------------------------------
52+
53+
def on_activate(self):
54+
host = self.device.host if isinstance(self.device, NetworkJoulescopeDevice) else None
55+
self.wrapper = AgentWrapper(host)
56+
try:
57+
self.proxy = self.wrapper.load("joulescope")
58+
self.proxy.open(self.device.serial, self.device.model, self.frequency)
59+
except Exception:
60+
# on_deactivate() only runs once the driver is active, so clean up
61+
# the (possibly remote) agent subprocess if opening the device fails.
62+
self.wrapper.close()
63+
self.wrapper = None
64+
self.proxy = None
65+
raise
66+
67+
def on_deactivate(self):
68+
try:
69+
self.proxy.close(self.device.serial, self.device.model)
70+
finally:
71+
self.wrapper.close()
72+
self.wrapper = None
73+
self.proxy = None
74+
75+
# -- statistics ---------------------------------------------------------
76+
77+
@Driver.check_active
78+
@step(result=True)
79+
def get_statistics(self):
80+
return self.proxy.get_statistics(self.device.serial, self.device.model)
81+
82+
@Driver.check_active
83+
@step()
84+
def start(self):
85+
self.proxy.start(self.device.serial, self.device.model)
86+
87+
@Driver.check_active
88+
@step(result=True)
89+
def stop(self):
90+
return self.proxy.stop(self.device.serial, self.device.model)
91+
92+
# -- sample capture -----------------------------------------------------
93+
94+
@Driver.check_active
95+
@step(args=["filename", "duration"])
96+
def capture(self, filename, signals=None, duration=None, frequency=None):
97+
if duration is None:
98+
raise ValueError("capture() requires a duration in seconds")
99+
if isinstance(self.device, NetworkJoulescopeDevice):
100+
# Record on the host the device is attached to, then copy the JLS
101+
# file back to the client and remove the remote copy.
102+
remote = f"/tmp/labgrid-joulescope-{uuid.uuid4()}.jls"
103+
self.proxy.capture(self.device.serial, self.device.model, remote, signals, duration, frequency)
104+
try:
105+
sshmanager.get_file(self.device.host, remote, filename)
106+
finally:
107+
self.proxy.remove(remote)
108+
else:
109+
self.proxy.capture(
110+
self.device.serial, self.device.model, os.fspath(filename), signals, duration, frequency
111+
)
112+
113+
# -- power switch (PowerProtocol) --------------------------------------
114+
115+
@Driver.check_active
116+
@step()
117+
def on(self):
118+
self.proxy.set_power(self.device.serial, self.device.model, True)
119+
120+
@Driver.check_active
121+
@step()
122+
def off(self):
123+
self.proxy.set_power(self.device.serial, self.device.model, False)
124+
125+
@Driver.check_active
126+
@step()
127+
def cycle(self):
128+
self.off()
129+
time.sleep(self.delay)
130+
self.on()

labgrid/protocol/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .consoleprotocol import ConsoleProtocol
44
from .linuxbootprotocol import LinuxBootProtocol
55
from .powerprotocol import PowerProtocol
6+
from .energyanalyzerprotocol import EnergyAnalyzerProtocol
67
from .filetransferprotocol import FileTransferProtocol
78
from .infoprotocol import InfoProtocol
89
from .digitaloutputprotocol import DigitalOutputProtocol
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import abc
2+
3+
4+
class EnergyAnalyzerProtocol(abc.ABC):
5+
"""Protocol for energy analyzers such as the Joulescope.
6+
7+
An energy analyzer continuously measures current, voltage and power and
8+
accumulates charge and energy. Drivers implementing this protocol expose
9+
the latest statistics, an accumulation window (start/stop) for charge and
10+
energy, and high-rate sample capture to a file.
11+
"""
12+
13+
@abc.abstractmethod
14+
def get_statistics(self):
15+
"""Return the latest measurement statistics as a dict.
16+
17+
The returned dict contains ``current``, ``voltage`` and ``power``
18+
sub-dicts (each with ``avg``, ``std``, ``min`` and ``max`` keys) as
19+
well as the accumulated ``charge_C`` (Coulombs) and ``energy_J``
20+
(Joules). Convenience values such as average current are read from
21+
this return value rather than via dedicated accessors.
22+
"""
23+
raise NotImplementedError
24+
25+
@abc.abstractmethod
26+
def start(self):
27+
"""Begin a charge/energy accumulation window."""
28+
raise NotImplementedError
29+
30+
@abc.abstractmethod
31+
def stop(self):
32+
"""End the accumulation window started by :meth:`start`.
33+
34+
Returns a dict with the accumulated ``energy_J`` (Joules),
35+
``charge_C`` (Coulombs) and the ``duration_s`` (seconds) of the window.
36+
"""
37+
raise NotImplementedError
38+
39+
@abc.abstractmethod
40+
def capture(self, filename, signals=None, duration=None, frequency=None):
41+
"""Capture high-rate samples to a file for the given duration.
42+
43+
``frequency`` (in Hz) sets the device sample rate for the capture. It
44+
is sticky: once set it remains in effect for subsequent captures on the
45+
same activated driver until changed again, rather than reverting to the
46+
device default.
47+
"""
48+
raise NotImplementedError

labgrid/remote/exporter.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,34 @@ def _get_params(self):
386386
}
387387

388388

389+
@attr.s(eq=False)
390+
class JoulescopeExport(USBGenericExport):
391+
"""ResourceExport for Joulescope energy analyzers"""
392+
393+
def __attrs_post_init__(self):
394+
# USBGenericExport imports the local class from resource.udev, but the
395+
# JoulescopeDevice lives in resource.joulescope, so build it here (the
396+
# ProviderGenericExport does the same for its own module).
397+
ResourceExport.__attrs_post_init__(self)
398+
self.data["cls"] = f"Network{self.cls}"
399+
from ..resource.joulescope import JoulescopeDevice
400+
401+
self.local = JoulescopeDevice(target=None, name=None, **self.local_params)
402+
403+
def _get_params(self):
404+
"""Helper function to return parameters"""
405+
return {
406+
"host": self.host,
407+
"busnum": self.local.busnum,
408+
"devnum": self.local.devnum,
409+
"path": self.local.path,
410+
"vendor_id": self.local.vendor_id,
411+
"model_id": self.local.model_id,
412+
"serial": self.local.serial,
413+
"model": self.local.model,
414+
}
415+
416+
389417
@attr.s(eq=False)
390418
class USBSDMuxExport(USBGenericExport):
391419
"""ResourceExport for USB devices accessed directly from userspace"""
@@ -578,6 +606,7 @@ def __attrs_post_init__(self):
578606
exports["AlteraUSBBlaster"] = USBGenericExport
579607
exports["SigrokUSBDevice"] = USBSigrokExport
580608
exports["SigrokUSBSerialDevice"] = USBSigrokExport
609+
exports["JoulescopeDevice"] = JoulescopeExport
581610
exports["USBSDMuxDevice"] = USBSDMuxExport
582611
exports["USBSDWireDevice"] = USBSDWireExport
583612
exports["USBSDWire3Device"] = USBSDWire3Export

labgrid/resource/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from .dediprogflasher import DediprogFlasher, NetworkDediprogFlasher
4848
from .httpdigitalout import HttpDigitalOutput
4949
from .sigrok import SigrokDevice
50+
from .joulescope import JoulescopeDevice
5051
from .fastboot import AndroidNetFastboot
5152
from .eth008 import Eth008DigitalOutput
5253
from .laa import LAASerialPort, LAAPowerPort, LAAUSBGadgetMassStorage, \

0 commit comments

Comments
 (0)