# Copyright 2022-2025 Jetperch LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from joulescope.deprecation import warn_once
from joulescope.parameters_v1 import PARAMETERS, PARAMETERS_DICT, name_to_value, value_to_name
from .stream_buffer import StreamBuffer
from joulescope.view import View
from pyjoulescope_driver import DeviceContext, DevicePath
import collections
import copy
import functools
import logging
import numpy as np
import queue
import threading
# The streaming signals common to all v1 devices, keyed by the canonical
# short name used by the 'signals' parameter. Each topic prefix provides
# '!data' and 'ctrl' subtopics. idx is the StreamBuffer buffer key.
_SIGNALS_BASE = {
'i': {'topic': 's/i/', 'idx': (1, 0)},
'v': {'topic': 's/v/', 'idx': (2, 0)},
'p': {'topic': 's/p/', 'idx': (3, 0)},
'r': {'topic': 's/i/range/', 'idx': (4, 0)},
'0': {'topic': 's/gpi/0/', 'idx': (5, 0)},
'1': {'topic': 's/gpi/1/', 'idx': (5, 1)},
}
# The extended signals available on the JS220 and JS320.
_SIGNALS_EXTENDED = {
'2': {'topic': 's/gpi/2/', 'idx': (5, 2)},
'3': {'topic': 's/gpi/3/', 'idx': (5, 3)},
'T': {'topic': 's/gpi/7/', 'idx': (5, 7)},
}
# Map the short signal names to StreamBuffer extended signal names.
_SIGNALS_SHORT_TO_EXTENDED = {'2': 'gpi2', '3': 'gpi3', 'T': 'trigger_in'}
# The default timeout for statistics_get and statistics_iter, in seconds.
_STATISTICS_TIMEOUT = 2.0
# The maximum number of statistics values that statistics_get and
# statistics_iter buffer for the caller: 50 seconds at the default 2 Hz.
_STATISTICS_QUEUE_LENGTH = 100
# The statistics source names and the legacy v0 alias.
_STATISTICS_SOURCE_NAMES = {'sensor', 'host'}
_STATISTICS_SOURCE_ALIASES = {'stream_buffer': 'host'}
# The stop_fn event for device removal: v0 DeviceEvent.COMMUNICATION_ERROR.
_EVENT_DEVICE_REMOVED = 1
[docs]
class Device:
def __init__(self, driver, device_path):
self.config = None
self._driver = driver
device_path = DevicePath(device_path.rstrip('/'))
self._log = logging.getLogger(__name__ + '.' + device_path.replace('/', '.'))
self._path = device_path
self._ctx = DeviceContext(driver, device_path) # replaced by open()
self.is_open = False
self._stream_cbk_objs = []
self._stream_cbk_objs_add = []
self._stop_fn = None
self._input_sampling_frequency = 0
self._output_sampling_frequency = 0
self._h_fs = 2000000 # value published to h/fs on open: the maximum streaming rate
self._statistics_callbacks = {} # source -> [cbk]
self._statistics_offsets = {} # source -> [duration, charge, energy]
self._statistics_queue = None # deque for statistics_get, when active
self._statistics_queue_cond = threading.Condition()
self._statistics_active = {} # source -> (ctrl, topic, fn) while subscribed
self._is_streaming = False
self._signals_map = dict(_SIGNALS_BASE)
self._streaming_topics = []
self._buffer_duration = 30
self.stream_buffer = None
self._on_stream_cbk = self._on_stream # hold reference for unsub
self._parameters = {}
self._parameters_override = {} # name -> device-specific Parameter
self._parameter_set_queue = []
for p in PARAMETERS:
if p.default is not None:
try:
self._parameters[p.name] = name_to_value(p.name, p.default)
except KeyError:
if p.validator is not None:
self._parameters[p.name] = p.validator(p.default)
else:
self._parameters[p.name] = p.default
def __str__(self):
_, model, serial_number = self._path.split('/') # keep "&" in bootloader mode
return f'{model.upper()}-{serial_number}'
@property
def device_path(self):
return self._path
@property
def driver(self):
"""The underlying pyjoulescope_driver.Driver instance."""
return self._driver
@property
def usb_device(self):
return self._path
@property
def input_sampling_frequency(self):
"""The original input sampling frequency."""
return self._input_sampling_frequency
@property
def output_sampling_frequency(self):
"""The output sampling frequency."""
return self._output_sampling_frequency
def _output_sampling_frequency_set(self, value):
"""Protected setter for output_sampling_frequency.
Applications should use: `parameter_set('sampling_frequency', value)`.
"""
self._output_sampling_frequency = value
if self.stream_buffer is not None:
self.stream_buffer.output_sampling_frequency = self._output_sampling_frequency
@property
def sampling_frequency(self):
"""The output sampling frequency."""
return self.output_sampling_frequency
@property
def buffer_duration(self):
"""The stream buffer duration."""
return self._buffer_duration
@buffer_duration.setter
def buffer_duration(self, value):
self._buffer_duration = value
if self.stream_buffer is not None:
self.stream_buffer.buffer_duration = self._buffer_duration
@property
def statistics_callback(self):
"""Get the first registered statistics callback."""
for cbks in self._statistics_callbacks.values():
if len(cbks):
return cbks[0]
return None
@statistics_callback.setter
def statistics_callback(self, cbk):
"""Set the statistics callback.
:param cbk: The callable(data) where data is a statistics data
structure. See the `statistics documentation <statistics.html>`_
for details on the data format.
This function will be called from the USB processing thread.
Any calls back into self MUST BE resynchronized.
"""
for source, cbks in list(self._statistics_callbacks.items()):
for unregister_cbk in list(cbks):
self.statistics_callback_unregister(unregister_cbk, source)
self.statistics_callback_register(cbk)
[docs]
def statistics_callback_register(self, cbk, source=None):
"""Register a statistics callback.
:param cbk: The callable(data) where data is a statistics data
structure. See the `statistics documentation <statistics.html>`_
for details on the data format.
This function will be called from the USB processing thread.
Any calls back into self MUST BE resynchronized.
See :meth:`statistics_get` to receive statistics on the
caller's thread instead.
:param source: The statistics source, which is one of:
* None (default): the model and scan config default,
see :attr:`statistics_source`.
* 'sensor': computed on the instrument.
* 'host': computed by the host driver from the full-rate
sample stream, so it requires streaming. 'stream_buffer'
is the legacy v0 name.
The JS110 supports both sources, even at the same time.
The JS220 and JS320 only compute statistics on the
instrument: they ignore 'host', with a DeprecationWarning.
:raise ValueError: If source is not a statistics source name.
WARNING: calling :meth:`statistics_callback` after calling this method
may result in unusual behavior. Do not mix these API calls.
"""
if cbk is None:
return
if not callable(cbk):
self._log.warning('Requested callback is not callable')
return
source = self._statistics_source_resolve(source)
cbks = self._statistics_callbacks.setdefault(source, [])
if not len(cbks):
self._statistics_start(source)
cbks.append(cbk)
[docs]
def statistics_callback_unregister(self, cbk, source=None):
"""Unregister a statistics callback.
:param cbk: The callback previously provided to
:meth:`statistics_callback_register`.
:param source: The source provided to
:meth:`statistics_callback_register`. None (default)
searches all sources.
"""
if source is None:
sources = [s for s, cbks in self._statistics_callbacks.items() if cbk in cbks]
else:
sources = [self._statistics_source_resolve(source)]
for source in sources:
cbks = self._statistics_callbacks.get(source, [])
if cbk in cbks:
cbks.remove(cbk)
if not len(cbks):
self._statistics_stop(source)
return
self._log.warning('statistics_callback_unregister: callback not registered')
@property
def statistics_source(self):
"""The default statistics source, which is one of:
* 'sensor': computed on the instrument.
* 'host': computed by the host driver from the full-rate
sample stream.
The JS220 and JS320 always use 'sensor'. The JS110 uses 'sensor'
when scanned with config='off', and 'host' otherwise.
:meth:`statistics_callback_register` selects the source for each
callback.
"""
return 'sensor'
def _statistics_sources(self):
"""The supported statistics sources.
:return: The dict mapping each source name to its
(ctrl topic, value topic). The ctrl topic is None when the
value topic is always enabled.
"""
return {'sensor': ('s/stats/ctrl', 's/stats/value')}
def _statistics_source_resolve(self, source):
"""Resolve a caller-provided source to one this device supports."""
default = self.statistics_source
if source is None:
return default
name = str(source).lower()
name = _STATISTICS_SOURCE_ALIASES.get(name, name)
if name not in _STATISTICS_SOURCE_NAMES:
raise ValueError(f'invalid statistics source {source!r}: '
f'use one of {sorted(_STATISTICS_SOURCE_NAMES)}')
if name not in self._statistics_sources():
model = self.model.upper()
warn_once(f'statistics_source_{self.model}',
f'The {model} computes statistics on the instrument: '
f'statistics source {source!r} is deprecated and ignored.')
return default
return name
def _on_stats(self, source, topic, value):
# The JS320 reports 16 Msps sample ids; the JS110 and JS220 report 2 Msps.
period = 1 / value['time'].get('sample_freq', {}).get('value', 2e6)
s_start, s_stop = [x * period for x in value['time']['samples']['value']]
offsets = self._statistics_offsets.get(source)
if offsets is None:
duration = s_start
charge = value['accumulators']['charge']['value']
energy = value['accumulators']['energy']['value']
offsets = self._statistics_offsets[source] = [duration, charge, energy]
duration, charge, energy = offsets
value['time']['range'] = {
'value': [s_start - duration, s_stop - duration],
'units': 's'
}
value['time']['delta'] = {'value': s_stop - s_start, 'units': 's'}
value['accumulators']['charge']['value'] -= charge
value['accumulators']['energy']['value'] -= energy
for k in value['signals'].values():
k['µ'] = k['avg']
k['σ2'] = {'value': k['std']['value'] ** 2, 'units': k['std']['units']}
if 'integral' in k:
k['∫'] = k['integral']
value['source'] = source
for cbk in list(self._statistics_callbacks.get(source, [])):
cbk(value)
def _statistics_start(self, source):
if self.is_open and source not in self._statistics_active:
ctrl, topic = self._statistics_sources()[source]
fn = functools.partial(self._on_stats, source)
self._statistics_active[source] = (ctrl, topic, fn)
if ctrl is not None:
self.publish(ctrl, 1)
self.subscribe(topic, 'pub', fn)
def _statistics_stop(self, source):
active = self._statistics_active.pop(source, None)
if self.is_open and active is not None:
ctrl, topic, fn = active
self.unsubscribe(topic, fn)
if ctrl is not None:
self.publish(ctrl, 0)
def _on_statistics_queue(self, value):
with self._statistics_queue_cond:
q = self._statistics_queue
if q is None:
return
if len(q) == q.maxlen:
self._log.warning('statistics_get queue full: dropping oldest value')
q.append(value)
self._statistics_queue_cond.notify_all()
def _statistics_queue_stop(self, unregister=True):
"""Stop statistics_get buffering and wake any waiting caller.
:param unregister: False to leave the driver untouched, such as
after device removal.
"""
with self._statistics_queue_cond:
q, self._statistics_queue = self._statistics_queue, None
self._statistics_queue_cond.notify_all()
if q is None:
return
if unregister:
self.statistics_callback_unregister(self._on_statistics_queue)
else:
for cbks in self._statistics_callbacks.values():
if self._on_statistics_queue in cbks:
cbks.remove(self._on_statistics_queue)
[docs]
def statistics_get(self, timeout=None):
"""Get the next statistics value on the caller's thread.
:param timeout: The maximum time to wait in float seconds.
None (default) waits 2 seconds.
:return: The statistics data structure, the same as provided to
:meth:`statistics_callback_register` callbacks.
:raise RuntimeError: If the device is not open.
:raise TimeoutError: If no statistics value arrives in time.
The first call starts buffering statistics values until
:meth:`close`, so consecutive calls return consecutive values
without gaps. The buffer holds the 100 most recent values,
which is 50 seconds at the default reduction_frequency of 2 Hz.
"""
if not self.is_open:
raise RuntimeError('statistics_get requires an open device')
timeout = _STATISTICS_TIMEOUT if timeout is None else float(timeout)
cond = self._statistics_queue_cond
q = None
with cond:
if self._statistics_queue is None:
q = collections.deque(maxlen=_STATISTICS_QUEUE_LENGTH)
self._statistics_queue = q
if q is not None: # outside the lock: the driver thread calls _on_statistics_queue
self.statistics_callback_register(self._on_statistics_queue)
with cond:
stale = self._statistics_queue is not q
if stale: # close or removal stopped q before the callback was added
for source, cbks in list(self._statistics_callbacks.items()):
if self._on_statistics_queue in cbks:
self.statistics_callback_unregister(self._on_statistics_queue, source)
break
with cond:
ready = cond.wait_for(self._statistics_queue_ready, timeout)
if self._statistics_queue is None:
raise RuntimeError('device closed during statistics_get')
if not ready:
raise TimeoutError(f'statistics_get timed out after {timeout} seconds')
return self._statistics_queue.popleft()
def _statistics_queue_ready(self):
q = self._statistics_queue
return q is None or len(q) > 0
[docs]
def statistics_iter(self, count=None, timeout=None):
"""Iterate over statistics values on the caller's thread.
:param count: The number of statistics values. None (default)
iterates until the device closes or the caller stops.
:param timeout: The maximum time to wait for each value in
float seconds. None (default) waits 2 seconds.
:return: The iterator over statistics data structures.
:raise RuntimeError: If the device is not open.
:raise TimeoutError: If a statistics value does not arrive in time.
Example::
for stats in device.statistics_iter(count=10):
print(stats['signals']['current']['µ']['value'])
See :meth:`statistics_get`.
"""
if not self.is_open:
raise RuntimeError('statistics_iter requires an open device')
def generate():
n = 0
while count is None or n < count:
try:
value = self.statistics_get(timeout)
except RuntimeError:
if not self.is_open:
return # device closed
raise
yield value
n += 1
return generate()
[docs]
def statistics_accumulators_clear(self):
"""Clear the charge and energy accumulators."""
self._statistics_offsets.clear()
[docs]
def view_factory(self):
"""Construct a new View into the device's data.
:return: A View-compatible instance.
"""
if self.stream_buffer is None:
raise RuntimeError('view_factory, but no stream buffer')
view = View(self.stream_buffer, self.calibration)
view.on_close = lambda: self.stream_process_unregister(view)
self.stream_process_register(view)
return view
[docs]
def parameters(self, name=None):
"""Get the list of :class:`joulescope.parameter.Parameter` instances.
:param name: The optional name of the parameter to retrieve.
None (default) returns a list of all parameters.
:return: The list of all parameters. If name is provided, then just
return that single parameters.
The parameter options reflect the values supported by this device.
For backwards compatibility, :meth:`parameter_set` also accepts the
legacy values of other Joulescope models where possible.
"""
params = [self._parameters_override.get(p.name, p) for p in PARAMETERS]
if name is not None:
for p in params:
if p.name == name:
return copy.deepcopy(p)
return None
return copy.deepcopy(params)
[docs]
def parameter_set(self, name, value):
"""Set a parameter value.
:param name: The parameter name
:param value: The new parameter value
:raise KeyError: if name not found.
:raise ValueError: if value is not allowed
"""
raise NotImplementedError()
[docs]
def parameter_get(self, name, dtype=None):
"""Get a parameter value.
:param name: The parameter name.
:param dtype: The data type for the parameter. None (default)
attempts to convert the value to the enum string.
'actual' returns the value in its actual type used by the driver.
:raise KeyError: if name not found.
"""
if name == 'current_ranging':
pnames = ['type', 'samples_pre', 'samples_window', 'samples_post']
values = [str(self.parameter_get('current_ranging_' + p)) for p in pnames]
return '_'.join(values)
p = PARAMETERS_DICT[name]
if p.path == 'info':
return self._parameter_get_info(name)
value = self._parameters[name]
if dtype == 'actual':
return value
try:
return value_to_name(name, value)
except KeyError:
return value
def _topic_make(self, topic):
"""Get the absolute topic, for backwards compatibility."""
return self._ctx.topic(topic.lstrip('/'))
[docs]
def publish(self, topic, value, timeout=None):
"""Publish to the underlying joulescope_driver instance.
:param topic: The publish topic.
:param value: The publish value.
:param timeout: The timeout in float seconds to wait for this operation
to complete. None waits the default amount.
0 does not wait and subscription will occur asynchronously.
"""
return self._ctx.publish(topic.lstrip('/'), value, timeout)
[docs]
def query(self, topic, timeout=None):
"""Query the underlying joulescope_driver instance.
:param topic: The publish topic to query.
:param timeout: The timeout in float seconds to wait for this operation
to complete. None waits the default amount.
0 does not wait and subscription will occur asynchronously.
:return: The value associated with topic.
"""
return self._ctx.query(topic.lstrip('/'), timeout)
[docs]
def subscribe(self, topic, flags, fn, timeout=None):
"""Subscribe to receive topic updates.
:param self: The driver instance.
:param topic: Subscribe to this topic string.
:param flags: The flags or list of flags for this subscription.
The flags can be int32 jsdrv_subscribe_flag_e or string
mnemonics, which are:
- pub: Subscribe to normal values
- pub_retain: Subscribe to normal values and immediately publish
all matching retained values. With timeout, this function does
not return successfully until all retained values have been
published.
- metadata_req: Subscribe to metadata requests (not normally useful).
- metadata_rsp: Subscribe to metadata updates.
- metadata_rsp_retain: Subscribe to metadata updates and immediately
publish all matching retained metadata values.
- query_req: Subscribe to all query requests (not normally useful).
- query_rsp: Subscribe to all query responses.
- return_code: Subscribe to all return code responses.
:param fn: The function to call on each publish. Note that python
dynamically constructs bound methods. To unsubscribe a method,
provide the exact same bound method instance to unsubscribe.
This constrain usually means that the caller needs to hold onto
the instance.method value passed to this function.
:param timeout: The timeout in float seconds to wait for this operation
to complete. None waits the default amount.
0 does not wait and subscription will occur asynchronously.
:raise RuntimeError: on subscribe failure.
"""
return self._ctx.subscribe(topic.lstrip('/'), flags, fn, timeout)
[docs]
def unsubscribe(self, topic, fn, timeout=None):
"""Unsubscribe a callback to a topic.
:param topic: Unsubscribe from this topic string.
:param fn: The callback function to unsubscribe.
:param timeout: The timeout in float seconds to wait for this operation
to complete. None waits the default amount.
0 does not wait and subscription will occur asynchronously.
"""
return self._ctx.unsubscribe(topic.lstrip('/'), fn, timeout)
[docs]
def unsubscribe_all(self, fn, timeout=None):
"""Unsubscribe a callback from all topics.
:param fn: The callback function to unsubscribe.
:param timeout: The timeout in float seconds to wait for this operation
to complete. None waits the default amount.
0 does not wait and subscription will occur asynchronously.
"""
return self._driver.unsubscribe_all(fn, timeout)
def _config_apply(self, config=None):
"""Apply a configuration set by scan.
:param config: The configuration string.
"""
pass
@property
def _signals_selected(self):
"""The list of selected signal short names."""
return self._parameters['signals'].split(',')
def _on_signals(self, value):
"""Validate the 'signals' parameter selection for this device.
:param value: The canonical comma-separated short-name string.
:raise ValueError: If this device does not support a selected signal.
The selection takes effect when streaming (re)starts.
"""
selected = value.split(',')
unsupported = [s for s in selected if s not in self._signals_map]
if unsupported:
raise ValueError(
f'signals not supported by {self.model}: {unsupported}')
if self._is_streaming:
self._log.warning(
'signals changed while streaming; takes effect on next start')
[docs]
def open(self, event_callback_fn=None, mode=None, timeout=None):
"""Open this device.
:param event_callback_fn: The function(event, message) to call on
asynchronous events, mostly to allow robust handling of device
errors. "event" is one of the :class:`DeviceEvent` values,
and the message is a more detailed description of the event.
:param mode: The open mode which is one of:
* 'defaults': Reconfigure the device for default operation.
* 'restore': Update our state with the current device state.
* 'raw': Open the device in raw mode for development or firmware update.
* None: equivalent to 'defaults'.
:param timeout: The timeout in seconds. None uses the default timeout.
"""
self._ctx = self._driver.open(self._path, mode, timeout)
self.is_open = True
self._statistics_offsets.clear() # the sample ids and accumulators may restart
self.publish('h/fs', self._h_fs)
while len(self._parameter_set_queue):
name, value = self._parameter_set_queue.pop(0)
self.parameter_set(name, value)
for source, cbks in self._statistics_callbacks.items():
if len(cbks):
self._statistics_start(source)
device = 'js110' if 'js110' in self._path.lower() else 'js220'
self.stream_buffer = StreamBuffer(self._buffer_duration,
frequency=self._input_sampling_frequency,
device=device,
output_frequency=self._output_sampling_frequency)
self._config_apply(self.config)
return self._ctx
[docs]
def close(self, timeout=None):
"""Close this device and release resources.
:param timeout: The timeout in seconds. None uses the default timeout.
"""
if not self.is_open:
return
self._statistics_queue_stop()
for source in list(self._statistics_active):
self._statistics_stop(source)
self.stop()
self.is_open = False
# notify and unregister the stream process objects (v0 compatible)
self._stream_process_call('close')
self._stream_cbk_objs.clear()
self._stream_cbk_objs_add.clear()
self.stream_buffer = None
return self._ctx.close(timeout) # also unsubscribes any remaining subscriptions
def _on_remove(self):
"""Handle device removal, on the driver thread.
The driver has already closed the device, so this only updates
the host-side state and must not block on the driver. Any
stop_fn receives event 1 (v0 DeviceEvent.COMMUNICATION_ERROR).
"""
if not self.is_open:
return
self._log.info('device removed')
self.is_open = False
self._statistics_queue_stop(unregister=False)
self._statistics_active.clear()
if self._is_streaming:
self._is_streaming = False
self._streaming_topics = []
fn, self._stop_fn = self._stop_fn, None
if callable(fn):
fn(_EVENT_DEVICE_REMOVED, 'device removed')
self._stream_process_call('stop')
self._stream_process_call('close')
self._stream_cbk_objs.clear()
self._stream_cbk_objs_add.clear()
self.stream_buffer = None
self._ctx.close(timeout=0) # release the subscriptions without waiting
@property
def firmware(self):
"""The running firmware image, which is one of:
* 'app': the application firmware.
* 'bootloader': the JS110 or JS220 bootloader, which the device
path shows with the "&" model prefix.
* 'recovery': the JS320 recovery image. The driver does not yet
report it, so a JS320 is always 'app'.
"""
return 'bootloader' if self._path.is_bootloader else 'app'
@property
def model(self):
"""The lowercase model, such as "js220"."""
return self._path.model
@property
def serial_number(self):
return self._path.serial_number
@property
def device_serial_number(self):
return self.serial_number
@property
def calibration(self):
return None
[docs]
def info(self):
"""Get the device information structure.
:return: The device information structure.
"""
raise NotImplementedError()
def _stream_process_call(self, method, *args, **kwargs):
rv = False
b, self._stream_cbk_objs, self._stream_cbk_objs_add = self._stream_cbk_objs + self._stream_cbk_objs_add, [], []
for obj in b:
fn = getattr(obj, method, None)
if not callable(fn):
self._stream_cbk_objs.append(obj)
continue
if obj.driver_active:
try:
rv |= bool(fn(*args, **kwargs))
self._stream_cbk_objs.append(obj)
except Exception:
self._log.exception('%s %s() exception', obj, method)
obj.driver_active = False
if not obj.driver_active:
try:
if hasattr(obj, 'close'):
obj.close()
except Exception:
self._log.exception('%s close() exception', obj)
return rv
def _on_stream(self, topic, value):
# runs on the driver thread; use a local reference since close()
# may set self.stream_buffer to None concurrently
b = self.stream_buffer
if b is None or not self._is_streaming:
# closed, or a message already dispatched when the driver thread
# itself called stop(): drop it so stream_notify ends with stop()
return False
_, e1 = b.sample_id_range
b.insert(topic, value)
e0, e2 = b.sample_id_range
if e1 == e2:
return False
if e0 == e2:
return False
rv = self._stream_process_call('stream_notify', b)
if rv:
self.stop()
if b.is_duration_max or b.is_contiguous_duration_max:
self.stop()
[docs]
def start(self, stop_fn=None, duration=None, contiguous_duration=None):
"""Start data streaming.
:param stop_fn: The function(event, message) called when the device stops.
The device can stop "automatically" on errors.
Call :meth:`stop` to stop from the caller.
This function will be called from the USB processing thread.
Any calls back into self MUST BE resynchronized.
:param duration: The duration in seconds for the capture.
:param contiguous_duration: The contiguous duration in seconds for
the capture. As opposed to duration, this ensures that the
duration has no missing samples. Missing samples usually
occur when the device first starts.
If streaming was already in progress, it will be restarted.
"""
self.stop()
# unwind any partial subscriptions left by a prior start() that
# failed mid-loop (e.g. device removal); no-op normally
topics, self._streaming_topics = self._streaming_topics, []
for topic in topics:
self.unsubscribe(topic + '!data', self._on_stream_cbk)
self.publish(topic + 'ctrl', 0, timeout=0)
selected = self._signals_selected
extras = [_SIGNALS_SHORT_TO_EXTENDED[s] for s in selected
if s in _SIGNALS_SHORT_TO_EXTENDED]
self.stream_buffer.extra_signals = extras # no-op when unchanged
self.stream_buffer.reset()
self.stream_buffer.duration_max = duration
self.stream_buffer.contiguous_duration_max = contiguous_duration
self._stop_fn = stop_fn
self._is_streaming = True # before subscribing: data may arrive during the loop
for name, info in self._signals_map.items():
b = self.stream_buffer.buffers.get(info['idx'])
if name in selected:
topic = info['topic']
if b is not None:
b.active = True
self.subscribe(topic + '!data', 'pub', self._on_stream_cbk)
self.publish(topic + 'ctrl', 1)
self._streaming_topics.append(topic)
elif b is not None:
b.active = False
self._stream_process_call('start', self.stream_buffer)
[docs]
def stop(self):
"""Stop data streaming.
:return: True if stopped. False if was already stopped.
This method is always safe to call, even after the device has been
stopped or removed.
"""
if self._is_streaming:
self._is_streaming = False
topics, self._streaming_topics = self._streaming_topics, []
for topic in topics:
# Wait, so no stream data arrives after stop() returns. The
# driver forces the wait off when the driver thread calls
# stop(), such as at duration max; _on_stream drops the rest.
self.unsubscribe(topic + '!data', self._on_stream_cbk)
self.publish(topic + 'ctrl', 0, timeout=0)
fn, self._stop_fn = self._stop_fn, None
if callable(fn):
fn(0, '') # status, message
self._stream_process_call('stop')
[docs]
def read(self, duration=None, contiguous_duration=None, out_format=None, fields=None):
"""Read data from the device.
:param duration: The duration in seconds for the capture.
The duration must fit within the stream_buffer.
:param contiguous_duration: The contiguous duration in seconds for
the capture. As opposed to duration, this ensures that the
duration has no missing samples. Missing samples usually
occur when the device first starts.
The duration must fit within the stream_buffer.
:param out_format: The output format which is one of:
* calibrated: The Nx2 np.ndarray(float32) with columns current and voltage.
* samples_get: The StreamBuffer samples get format. Use the fields
parameter to optionally specify the signals to include.
* None: equivalent to 'calibrated'.
:param fields: The fields for samples_get when out_format=samples_get.
If streaming was already in progress, it will be restarted.
If neither duration or contiguous duration is specified, the capture
will only be stopped by callbacks registered through
:meth:`stream_process_register`.
"""
self._log.info('read(duration=%s, contiguous_duration=%s, out_format=%s)',
duration, contiguous_duration, out_format)
if out_format not in ['calibrated', 'samples_get', None]:
raise ValueError(f'Invalid out_format {out_format}')
if duration is None and contiguous_duration is None:
raise ValueError('Must specify duration or contiguous_duration')
duration_max = len(self.stream_buffer) / self._output_sampling_frequency
if contiguous_duration is not None and contiguous_duration > duration_max:
raise ValueError(f'contiguous_duration {contiguous_duration} > {duration_max} max seconds')
if duration is not None and duration > duration_max:
raise ValueError(f'duration {duration} > {duration_max} max seconds')
q = queue.Queue()
def on_stop(event=0, message=''):
self._log.info('received stop callback: pending stop')
q.put(message)
self.start(on_stop, duration=duration, contiguous_duration=contiguous_duration)
message = q.get()
self.stop()
if self.stream_buffer is None: # such as an unplug, which closes the device
raise RuntimeError(f'read stopped: {message or "device closed"}')
start_id, end_id = self.stream_buffer.sample_id_range
self._log.info('read available range %s, %s', start_id, end_id)
if contiguous_duration is not None:
start_id = end_id - int(contiguous_duration * self._output_sampling_frequency)
elif duration is not None:
start_id = end_id - int(duration * self._output_sampling_frequency)
if start_id < 0:
start_id = 0
self._log.info('read actual %s, %s', start_id, end_id)
if out_format in ['calibrated', None]:
data = self.stream_buffer.samples_get(start_id, end_id, fields=['current', 'voltage'])
i = data['signals']['current']['value']
v = data['signals']['voltage']['value']
return np.hstack([np.reshape(i, (-1, 1)), np.reshape(v, (-1, 1))])
else:
return self.stream_buffer.samples_get(start_id, end_id, fields=fields)
@property
def is_streaming(self):
"""Check if the device is streaming.
:return: True if streaming. False if not streaming.
"""
return self._is_streaming
[docs]
def stream_process_register(self, obj):
"""Register a stream process object.
:param obj: The instance compatible with :class:`StreamProcessApi`.
The instance must remain valid until its :meth:`close` is
called.
Call :meth:`stream_process_unregister` to disconnect the instance.
"""
if self._is_streaming and hasattr(obj, 'start'):
obj.start(self.stream_buffer)
obj.driver_active = True
self._stream_cbk_objs_add.append(obj)
[docs]
def stream_process_unregister(self, obj):
"""Unregister a stream process object.
:param obj: The instance compatible with :class:`StreamProcessApi` that was
previously registered using :meth:`stream_process_register`.
"""
obj.driver_active = False
[docs]
def status(self):
"""Get the current device status.
:return: A dict containing constant status information.
Deprecated: the v1 backend has no status to report, so this
always returns the same values.
"""
warn_once('status', 'Device.status() is deprecated: it returns constant values.')
return {
'driver': {
'settings_result': {
'value': 0,
'units': ''},
'fpga_frame_counter': {
'value': 0,
'units': 'frames'},
'fpga_discard_counter': {
'value': 0,
'units': 'frames'},
'sensor_flags': {
'value': 0,
'format': '0x{:02x}',
'units': ''},
'sensor_i_range': {
'value': 0,
'format': '0x{:02x}',
'units': ''},
'sensor_source': {
'value': 0,
'format': '0x{:02x}',
'units': ''},
'return_code': {
'value': 0,
'format': '{}',
'units': '',
},
}
}
def __enter__(self):
"""Device context manager, automatically open."""
self.open()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Device context manager, automatically close."""
self.close()
def _query_gpi_value(self):
try:
return self._ctx.publish_and_wait('s/gpi/+/!req', 0, 's/gpi/+/!value', timeout=1.0)
except TimeoutError as ex:
raise RuntimeError('_query_gpi_value timed out') from ex
[docs]
def extio_status(self):
"""Read the EXTIO GPI value.
:return: A dict containing the extio status. Each key is the status
item name. The value is itself a dict with the following keys:
* name: The status name, which is the same as the top-level key.
* value: The actual value
* units: The units, if applicable.
* format: The recommended formatting string (optional).
The most interesting key is "gpi_value" which returns the present
general purpose input signal values. The remaining keys simply
copy parameter settings for convenience.
"""
gpi_value = self._query_gpi_value()
status = {
'flags': {
'value': 0,
'units': ''},
'trigger_source': {
'value': self.parameter_get('trigger_source'),
'units': ''},
'current_lsb': {
'value': self.parameter_get('current_lsb'),
'units': ''},
'voltage_lsb': {
'value': self.parameter_get('voltage_lsb'),
'units': ''},
'gpo0': {
'value': self.parameter_get('gpo0'),
'units': ''},
'gpo1': {
'value': self.parameter_get('gpo1'),
'units': ''},
'gpi_value': {
'value': gpi_value,
'units': '',
},
'io_voltage': {
'value': self.parameter_get('io_voltage'),
'units': 'mV',
},
}
for key, value in status.items():
value['name'] = key
return status