Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ jobs:
- os: ubuntu-latest
python: '3.11'
toxenv: py
- os: ubuntu-latest
python: '3.12'
toxenv: py
- os: ubuntu-latest
python: '3.13'
toxenv: py
- os: ubuntu-latest
python: '3.14'
toxenv: py
# windows
- os: windows-latest
python: "3.10"
Expand Down
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@
- add support for control server
- add shared sample and window transform modules
- add virtual channel runtime and vadd command

## 1.0.1 (23/03/2026)

- improve pdevinfo output
- new triggering logic
- add version command
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ Plugins are automatically detected by Nxscli.
Available plugins:

* [nxscli-mpl](https://github.com/railab/nxscli-mpl) - Matplotlib extension
* [nxscli-pqg](https://github.com/railab/nxscli-pqg) - PyQtGraph extension

## Plugins Planned

* Stream data as audio (inspired by audio knock detection systems)
* PyQtGraph support

## Installation

Expand Down
133 changes: 133 additions & 0 deletions docs/triggers.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
========
Triggers
========

Overview
========

Nxscli supports software triggers configured globally with ``trig`` or
per-plugin with ``--trig``.

Current trigger types:

* ``on`` - always on
* ``off`` - always off
* ``er`` - edge rising
* ``ef`` - edge falling
* ``we`` - window enter
* ``wx`` - window exit

Current capture modes:

* ``mode=start_after`` - default behavior, emit data only after trigger
* ``mode=stop_after`` - stream data until trigger, then stop after ``post``
samples

Syntax
======

Format:

.. code-block:: text

[channel]:[trigger][#source][@vector],[legacy_hoffset],[level_or_low][,high][,name=value...]

Supported named options:

* ``mode=start_after|stop_after``
* ``pre=<samples>`` - explicit pre-trigger samples for start-after capture
* ``post=<samples>`` - post-trigger samples for stop-after capture
* ``holdoff=<samples>`` - reserved for upcoming repeated/scope modes
* ``rearm=true|false`` - reserved for upcoming repeated/scope modes

Notes:

* The positional ``hoffset`` field is preserved for backward compatibility and
currently acts as legacy pre-trigger history.
* Advanced trigger capture modes currently target NumPy block streams.
* Trigger source can be cross-channel via ``#chan`` and vector index via
``@idx``.
* Window triggers use positional parameters ``hoffset,low,high``.

Trigger Semantics
=================

All edge and window triggers are evaluated on consecutive sample pairs.
That means the trigger boundary is defined between sample ``n`` and
sample ``n+1``, not "on" one single sample value.

Edge triggers:

* ``er`` fires when sample ``n`` is at or below ``level`` and sample
``n+1`` is above ``level``.
* ``ef`` fires when sample ``n`` is at or above ``level`` and sample
``n+1`` is below ``level``.

Window triggers:

* ``we`` fires when sample ``n`` is outside ``[low, high]`` and sample
``n+1`` is inside ``[low, high]``.
* ``wx`` fires when sample ``n`` is inside ``[low, high]`` and sample
``n+1`` is outside ``[low, high]``.

Trigger Boundary
================

For ``er``, ``ef``, ``we``, and ``wx``, the trigger point belongs to the
transition between sample ``n`` and sample ``n+1``.

That means these triggers are boundary-based, not whole-sample events.
Any downstream consumer should treat the trigger position as the crossing
between the two samples that satisfied the trigger condition.

Downstream Delivery
===================

Trigger events are delivered together with normal stream payload handling.
Plugins first read triggered data from their queue, then read the matching
trigger event metadata for that payload batch.

The trigger event metadata currently includes:

* trigger position within the emitted payload
* channel id associated with the emitted event
* capture mode

Examples
========

Always on:

.. code-block:: bash

python -m nxscli dummy trig "g:on" chan 17 pprinter 8

Start after trigger with pre-trigger history:

.. code-block:: bash

python -m nxscli dummy trig "g:er#17,0,0.5,pre=16" chan 17 pnpsave 64 /tmp/nxs_trigger_pre

Stop after trigger with post-trigger tail:

.. code-block:: bash

python -m nxscli dummy trig "g:er#17,0,0.5,mode=stop_after,post=32" chan 17 pnpsave 256 /tmp/nxs_trigger_post

Vector trigger on deterministic mixed vector channel:

.. code-block:: bash

python -m nxscli dummy trig "g:er#25@1,0,0.5,pre=8" chan 25 pnpsave 64 /tmp/nxs_trigger_vec

Window enter trigger on deterministic sine source:

.. code-block:: bash

python -m nxscli dummy trig "g:we#22,0,-0.25,0.25,pre=16" chan 22 pnpsave 64 /tmp/nxs_trigger_window_enter

Window exit trigger on deterministic sine source:

.. code-block:: bash

python -m nxscli dummy trig "g:wx#22,0,-0.25,0.25,pre=16" chan 22 pnpsave 64 /tmp/nxs_trigger_window_exit
15 changes: 11 additions & 4 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ with various channel configurations (based on ``pcap`` from ``nxscli-mpl``):
Library integration guide:

* :doc:`library`
* :doc:`triggers`

Interface Commands
------------------
Expand Down Expand Up @@ -69,10 +70,15 @@ Supported interface commands:
- 14: hist_bimodal - vdim = 1, deterministic bi-modal
- 15: xy_lissajous - vdim = 2, correlated XY signal
- 16: polar_theta_radius - vdim = 2, (theta, radius) signal
- 17: step_up_once - vdim = 1, one rising step
- 18: step_down_once - vdim = 1, one falling step
- 19: pulse_square_20p - vdim = 1, periodic square pulse (20% duty)
- 20: pulse_single_sparse - vdim = 1, one-sample pulse every 250 samples
- 17: step_low_to_high - vdim = 1, one low-to-high step
- 18: step_high_to_low - vdim = 1, one high-to-low step
- 19: square_wave_20p - vdim = 1, periodic square wave (20% duty)
- 20: impulse_sparse - vdim = 1, one-sample impulse every 250 samples
- 21: square_wave_50p - vdim = 1, periodic square wave (50% duty)
- 22: sine_slow - vdim = 1, slow sine wave
- 23: impulse_clustered - vdim = 1, clustered impulses
- 24: impulse_once_ref - vdim = 1, one-shot impulse reference
- 25: vec3_mixed_steps - vdim = 3, mixed vector step source

* ``serial`` - select serial port NxScope interface

Expand All @@ -95,6 +101,7 @@ Available configuration commands:
Optional, at default all channels are always-on.

Triggers can be configured per channel with the option ``--trig``.
For syntax, modes, and validation commands see :doc:`triggers`.

* ``vadd`` - add virtual channel in `nxscli` virtual runtime.

Expand Down
11 changes: 7 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,26 @@ build-backend = 'setuptools.build_meta'

[project]
name = "nxscli"
version = "1.0.0"
version = "1.0.1"
authors = [{name = "raiden00", email = "[email protected]"}]
description = "Nxscope CLI client"
license = {text = "Apache-2.0"}
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"nxslib>=1.0.0",
"nxslib>=1.0.1",
"click>=8.1",
"numpy"
"numpy",
"rich>=13.0"
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Embedded Systems",
"Operating System :: OS Independent",
]
Expand Down
17 changes: 15 additions & 2 deletions src/nxscli/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,31 @@ def main(
control_endpoint: str,
) -> bool:
"""Nxscli - Command-line clinet to the NxScope."""
click_ctx = click.get_current_context()

ctx.debug = debug
if debug: # pragma: no cover
logger.setLevel("DEBUG")
else:
logger.setLevel("INFO")

ctx.phandler = PluginHandler(plugins_list)
parse = Parser()
ctx.parser = parse
ctx.triggers = {}
ctx.nxscope_plugins = []

if click_ctx.invoked_subcommand == "version":
return True

ctx.phandler = PluginHandler(plugins_list)
if control_server:
try:
ctx.nxscope_plugins.append(ControlServerPlugin(control_endpoint))
except Exception:
ctx.phandler.cleanup()
raise

click.get_current_context().call_on_close(cli_on_close)
click_ctx.call_on_close(cli_on_close)

return True

Expand Down Expand Up @@ -212,13 +218,20 @@ def cli_on_close(ctx: Environment) -> bool:

def click_final_init() -> None:
"""Handle final Click initialization."""
# add standalone commands
for cmd in commands_list:
if cmd.name == "version":
main.add_command(cmd)

# add interfaces
for intf in interfaces_list:
main.add_command(intf)

# add commands to interfaces
for group in interfaces_list:
for cmd in commands_list:
if cmd.name == "version":
continue
group.add_command(cmd)


Expand Down
62 changes: 57 additions & 5 deletions src/nxscli/cli/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,56 @@ class Trigger(click.ParamType):
req_global = "g"
req_vect = "@"

@staticmethod
def _parse_bool(value: str) -> bool:
"""Parse common boolean tokens."""
norm = value.strip().lower()
if norm in ("1", "true", "yes", "on"):
return True
if norm in ("0", "false", "no", "off"):
return False
raise click.BadParameter(f"invalid trigger boolean value: {value}")

@staticmethod
def _parse_channel_ref(value: str) -> int | ChannelRef:
"""Parse physical or virtual trigger source token."""
if value.startswith("v"):
virt = value[1:]
if not virt.isnumeric():
raise click.BadParameter(
"virtual trigger source must be like v0"
)
return ChannelRef.virtual(int(virt))
return int(value)

def _parse_named_args(
self, cfg: list[str]
) -> tuple[list[str], dict[str, Any]]:
"""Split positional and named trigger arguments."""
positional: list[str] = []
named: dict[str, Any] = {}

for item in cfg:
if "=" not in item:
positional.append(item)
continue

key, raw = item.split("=", 1)
if key == "mode":
named["mode"] = raw
elif key == "pre":
named["pre_samples"] = int(raw)
elif key == "post":
named["post_samples"] = int(raw)
elif key == "holdoff":
named["holdoff"] = int(raw)
elif key == "rearm":
named["rearm"] = self._parse_bool(raw)
else:
raise click.BadParameter(f"unsupported trigger option: {key}")

return positional, named

def convert(
self, value: Any, param: Any, ctx: Any
) -> dict[int, DTriggerConfigReq]:
Expand All @@ -135,15 +185,17 @@ def convert(
if vect_idx != -1 and cross_idx != -1:
if vect_idx > cross_idx:
trg = tmp[0][:cross_idx]
cross = int(tmp[0][cross_idx + 1 : vect_idx])
cross = self._parse_channel_ref(
tmp[0][cross_idx + 1 : vect_idx]
)
vect = int(tmp[0][vect_idx + 1 :])
else:
trg = tmp[0][:vect_idx]
vect = int(tmp[0][vect_idx + 1 : cross_idx])
cross = int(tmp[0][cross_idx + 1 :])
cross = self._parse_channel_ref(tmp[0][cross_idx + 1 :])
elif vect_idx == -1 and cross_idx != -1:
trg, cross_s = tmp[0].split(self.req_cross)
cross = int(cross_s)
cross = self._parse_channel_ref(cross_s)
vect = 0
elif vect_idx != -1 and cross_idx == -1:
trg, vect_s = tmp[0].split(self.req_vect)
Expand All @@ -154,7 +206,7 @@ def convert(
vect = 0
cross = None

cfg = tmp[1:]
cfg, named = self._parse_named_args(tmp[1:])
# special case for global configuration
if schan == self.req_global:
chan = -1
Expand All @@ -165,7 +217,7 @@ def convert(
if cross == chan:
cross = None

req = DTriggerConfigReq(trg, cross, vect, cfg)
req = DTriggerConfigReq(trg, cross, vect, cfg, **named)
ret[chan] = req
return ret

Expand Down
Loading
Loading