Custom Decoders
Custom Decoders
What a Custom Decoder Is
A Custom Decoder is a small Python script you can attach to one of the four device ports (A, B, C, D) to turn captured digital and analog samples into annotation rows of your own design. The script runs after capture has stopped, walks the captured buffer end-to-end, and emits decoded text and numeric values that appear in the waveform display, the decode list, the PacketPresenter, the trigger system, and the .aft AI export, exactly like a built-in decoder.
Custom Decoders are aimed at protocols that the built-in hardware and software decoders do not cover, and at one-off measurement scripts (bit-bang waveforms, mixed analog/digital state machines, encoder counters, custom serial framings). You can ship them with your project, the script is saved inside the .active capture file, so anyone who opens the capture sees exactly what you saw.
Each port can host one decoder at a time. With four ports, four different decoders can run against the same capture, and they execute one after another in slot order A → B → C → D.
The Decoder Folder
Every Custom Decoder lives as a single .py file in the decoder folder (also called the registry folder). The application scans this folder for .py files, parses the metadata at the top of each file, and uses that to populate the picker.
Default location
By default the decoder folder is <working directory>/decoders. The working directory is the one shown on the Capture File Location row in the Settings panel. The decoder folder is shared across all four pods - it is at the bare working directory, not nested under a per-pod sub-folder.
The first time the application launches, it creates this folder and copies in the bundled example decoders (UART, I²C, SPI, I³C, CAN, LIN, Modbus RTU, PWM, Manchester, DMX-512, MIDI, HD44780, RS-232/485, 1-Wire, PS/2, HART, I²S, SMBus, PMBus, QSPI, RGMII, WS2812, Quadrature, DHT, 4-20 mA Loop, SWD, JTAG, and more), the decoder_template.py starter, the active_pro.py runtime module, and three small test_decoder_*.py smoke-tests.
Updates to bundled decoders
On every launch, the application reconciles the bundled example decoders against your folder:
- You have not touched the file and we shipped an update - the bundled version overwrites yours in place.
- You edited the file and we shipped an update - the new bundled version is installed alongside as
OriginalName_v2.py(or_v3, ...). Your edited version is left untouched. Both appear in the picker. - You edited the file and we did not ship an update - your file is left alone.
- Core runtime files (
active_pro.py,decoder_template.py) are always refreshed in place; a stale runtime cannot be allowed to drift from the host application.
This means you can safely edit any bundled decoder. Updates we ship later will not stomp your edits, but you will be able to see the new bundled version side-by-side if you want to compare.
Pointing at a different folder
The decoder folder is configured in Settings → Decoder Directory:
- Follow working directory checkbox (default on), the decoder folder tracks the working directory.
- Decoder folder path field, only editable when "Follow working directory" is off. Browse to any folder on disk. The application reads
.pyfiles from there. - Reset button, restores "Follow working directory" and clears the custom path.
The setting is per-user (stored in your OS-level application settings), not per-pod, so all four pods share the same decoder folder.
Opening the folder
There are three ways to open the decoder folder in your system file explorer:
- File menu → Open Decoder Folder.
- Decoder Picker → Open Decoder Folder button.
- Clicking the folder path link in the Decoder Picker's header banner.
Attaching a Custom Decoder to a Port
The Bus Decoder dropdown on each port (the same dropdown you use to pick UART, SPI, I²C, etc.) groups its entries into three sections:
- Real-Time Hardware Decode - runs on the FPGA inside the pod during capture.
- Real-Time Software Decode - runs on the host during capture.
- Post-Capture Python Decode - Custom Decoders. Runs after capture stops.
The Post-Capture Python Decode section contains a single entry: CHOOSE CUSTOM DECODER…. Selecting it opens the Decoder Picker. Once you pick a decoder, the dropdown's collapsed text becomes the picked decoder's filename so you can tell at a glance which decoder each port is running. If you reopen the dropdown, the "CHOOSE CUSTOM DECODER…" prompt is shown there instead of the filename, so the popup is always an action prompt, not a status indicator.
Switching a port away from a Custom Decoder (back to UART, OFF, etc.) clears that port's script, parameters, and display name. To get them back you have to re-select the decoder from the picker.
The Decoder Picker
The picker is a single dialog that lists every .py file in the decoder folder that has a valid DECODER_MYNAME line.
Header banner
Across the top is a label that shows the exact folder being read: Registry folder: <path>. The path is a clickable link that opens that folder in your file explorer. If the list of decoders ever looks wrong, the banner tells you immediately which folder the picker is looking at.
Filter
Below the banner is a search box labeled Filter:. Typing in it filters the table to rows whose Name or Description matches what you typed, case-insensitive.
Columns
The table has four columns:
- Name - the filename without
.py. This is what the application uses internally as the decoder's identity, and what appears in the port's Bus Decoder dropdown after you pick it. - Description - the first free-form comment line at the top of the script (after
DECODER_MYNAMEand anyPARAMlines). One sentence is best, multi-line descriptions get truncated. - Date Modified - the file's last-modified time.
- Size - the file size, in B, KB, or MB.
Click a column header to sort. The Description column is the elastic one and grows as you resize the dialog.
Live refresh
The picker watches the decoder folder while it is open. If you add, rename, delete, or edit any .py file in the folder (in Explorer or any other editor), the table refreshes within about a quarter-second without losing the row you had selected.
Buttons
- Open Decoder Folder - opens the registry folder in your file explorer. Same as the link in the banner.
- New... - prompts for a new decoder name, creates
<name>.pyin the registry folder fromdecoder_template.py, and opens it in your registered Python editor. On Windows the file is opened with the system's "edit" verb (Notepad, VS Code, Notepad++, whatever you have set), not the launcher, so the file does not execute. On macOS/Linux the containing folder opens because there is no portable equivalent of the Windows "edit" verb; pick your editor manually. - Select - accepts the picker with the currently highlighted row. Disabled until something is selected. Double-clicking a row is equivalent.
- Cancel - closes the picker. The port reverts to whatever decoder it had before.
Picking the same decoder twice
If you reopen the picker on a port that already has a decoder loaded, and pick the same one again, nothing reprocesses. The application only marks "changes pending" when the decoder actually changes.
Configuring Parameters: the Gear Button
Once a port has a Custom Decoder attached, two small buttons appear next to its Bus Decoder dropdown:
- A gear icon (parameters)
- A pencil icon (script editor)
The gear is only enabled if the script declares one or more PARAM: lines (see the Authoring section). Clicking it opens the parameter dialog, which is built dynamically from those lines.
The dialog title is Configure <decoder name>. Each parameter appears as one labeled row. The control type depends on the parameter's declared type:
select- drop-down list with one option per comma-separated value.int- integer spin box. If the script gives amin,maxrange, the spin box clamps to that range; otherwise the full 32-bit integer range is allowed.float- floating-point spin box with 6 decimal places. Samemin,maxrule asint.bool- checkbox.string- free-text field.digital_channel- spin box clamped to 0-7. Names a digital channel the decoder will read.analog_channel- spin box clamped to 1-8. Names an analog channel the decoder will read. Active-Pro has channels 1-4, Active-Pro Ultra has 1-8, the Active Debugger has no analog channels. The spin box does not gate the range by connected model, so you are responsible for not picking a channel your pod does not have.
Save stores the new parameter values into the port's configuration and marks changes pending. Cancel discards your edits.
Automatic channel activation
digital_channel and analog_channel parameters do more than just pass a number to the script. As soon as you save the dialog, the named channels are forced ON for capture, even if you had previously disabled them on the Inputs tab. This is also true at attach time: when you pick a decoder, each digital_channel parameter is seeded so the port's home pair is used first (A → channels 0/1, B → 2/3, C → 4/5, D → 6/7), with any further digital_channel parameters continuing modulo 8. analog_channel parameters keep the script's default.
Channel conflict warning
When you save the gear dialog, the application checks whether the channels this decoder now claims overlap with channels claimed by another port, a Custom Decoder on another port, or a built-in decoder whose protocol uses those channels.
If there is a collision, a modal warning lists each conflict, e.g.:
Port C's decoder is using channels already in use by:
Port A (UART): logic 0 Port B (My SPI): logic 2, analog CH1
Your change is saved as-is. No other ports are modified. Reopen the gear dialog to pick different channels if needed. Built-in hardware decoders on other ports do reserve their channels for conflict detection (the FPGA taps those lines whether your script wants them or not), so they participate in the warning even though they do not appear on the Inputs tab as "forced on".
Editing the Script: the Pencil Button
The pencil opens the in-application script editor for that port's decoder. It is a black-background code editor with:
- Line numbers in a gutter on the left. Python tracebacks identify failures by line number, so this lets you jump straight to the line the traceback mentions.
- Python syntax highlighting - keywords, strings, comments, numbers.
- Monospace font - uses the same source-code font as the PacketPresenter editor.
- 4-space tab stops.
- Current-line highlight - a subtle dark-grey band on the line your cursor is on.
The title bar reads Decoder Editor -- <decoder name>.py so you cannot lose track of which port's script you are editing.
Buttons across the bottom
- Import - load a
.pyfile from anywhere on disk into the editor. Replaces the current text. - Export - save the editor contents as a
.pyfile. Defaults to the decoder folder. If you save into the decoder folder over an existing file, a confirmation prompt appears. - Help - opens the online manual page for Custom Decoders in your browser.
- Save - applies your edits to the port's in-memory script and closes the editor. Save does not write back to disk - the script is held in memory and saved as part of the next
.activefile. To make your edits available to other ports or future sessions, use Export. - Cancel - closes the editor without applying changes.
How Save and Export differ
This trips people up the first time, so it is worth being explicit:
- Save updates the in-memory script attached to this port. It marks the change pending so APPLY CHANGES will pick it up. The original
.pyfile in the decoder folder is not modified. - Export writes the editor contents to disk. Use this if you want your edits to show up in the picker for other ports, in other sessions, or alongside the bundled examples.
- The Picker's New... button writes a fresh
.pyto disk and opens it externally. Use it when you are starting from scratch and want to use your own editor.
The reason for the split: a .active capture file you share with a colleague should contain the script as it ran against the data, not a path into your folder. So Save keeps the script with the capture; Export publishes it for reuse.
Running the Decoder: APPLY CHANGES
Custom Decoders run after capture has stopped, not during capture. This is a deliberate design choice: it lets the decoder freely walk forward and backward through the data, lets you re-run a decoder with different parameters against the same capture without re-capturing, and means a slow decoder cannot drop samples.
The runner is triggered in three ways:
- At the end of every live capture - when STOP is pressed (or the buffer fills, or the trigger's post-trigger condition is met), the decoder pipeline runs as part of post-capture processing.
- When opening a saved
.activefile - if the file contains Custom Decoder scripts, they re-run against the loaded capture. - The APPLY CHANGES button at the bottom of the window - re-runs the pipeline against the current capture, picking up any decoder script edits, gear-dialog parameter changes, picker re-selections, or trigger setting changes you have made since the last run. The button is enabled when there are pending changes and no capture is in progress; if you are still capturing, click STOP first.
The full pipeline order is:
- Custom Decoders - A → B → C → D, one at a time.
- Packet Presenters - runs against whatever decoded data exists.
- Trigger Search - re-scans for trigger matches across the now-current channel data.
Status bar feedback
While the decoders run, the status bar shows live progress:
Processing Custom Decoder: My SPI, 37%, 1284 records
The percentage is the decoder's progress through the captured logic and analog buffers. "Records" is how many annotations the Python script has emitted so far. After each decoder completes you see Custom Decoder 'X' complete., and after all decoders finish, Custom Decoders complete..
Cancelling
Hitting the CAPTURE button during a post-capture decoder run cancels the remainder. The decoder currently running is killed; any annotations it had already produced stay in the display. Decoders queued after it are skipped.
Driving Custom Decoders from a Script or AI Assistant
The Automation API and the MCP server do not expose any commands for attaching a Custom Decoder, setting its parameters, or editing its script. Custom Decoder selection and configuration is a GUI-only operation. This is deliberate: decoder scripts and gear-dialog parameters are tightly coupled, and the safe way to apply them from a script is to load a known-good configuration that already has them set.
Recommended workflow
- Configure in the GUI. Pick the Custom Decoder mode for the desired port, choose the script in the decoder picker, set parameters in the gear dialog, and configure any channel labels, units, threshold, trigger, or buffer settings you want to apply alongside.
- Save a configuration file. Use File → Save Configuration (or the
SaveConfiguration <path>API command) to capture the current settings to a.inifile. Custom Decoder selection, parameters, and the path to the script are included. - Drive from your script. From a Python script, an AI assistant, or any other automation client, send
OpenConfiguration <path>to load that configuration on the running application. If a capture is already loaded and you want to reprocess it through the freshly loaded decoders, sendApplyChangesafterOpenConfiguration. - Run captures normally.
StartCaptureandStopCapturework as usual; the decoder pipeline runs at the end of every capture using whatever was loaded by step 3.
Why no API for attach / parameters / script
A Custom Decoder is a Python file on disk, plus the gear-dialog parameter values, plus the port assignment. Each piece has to match the others for the decoder to produce correct output. Exposing setters for each piece individually would invite scripts that set them in inconsistent combinations. Loading a configuration file applies the whole set atomically.
If you need to change one parameter from a script, edit the saved configuration file directly with a text editor (or have the AI edit it), then OpenConfiguration to reload. The configuration file is a plain INI format.
What you can do from a script
Around the Custom Decoder, the API still drives everything: capture start/stop, cursor and zoom control, reading the decoder's emitted text and value channels for export, searching for decoded strings, triggering on text matches against the decoder output, and exporting AI Snapshots that include the decoder's output. See the Automation API chapter for the full command set.
Choosing the Python Interpreter
By default, Custom Decoders run inside a bundled Python interpreter that ships with the application (python_runtime\win64\python.exe on Windows). You do not need to install Python yourself, everything required is already in the application folder.
If you need to use your own Python, for example because your decoder imports a third-party package not bundled with the application, switch interpreters in Settings → Python Interpreter:
- Bundled (default radio), uses the interpreter that ships with the application.
- Custom (other radio), uses an interpreter at a path you provide. The browse button opens a file picker for
python.exe. The application runspython --versionon the path you pick and shows the result next to Detected version: so you can verify the binary works.
The application falls back to bundled if your custom path is empty or no longer exists. Restarting is not required after switching interpreters, the next decoder run uses the new setting.
What modules are available
The bundled interpreter is a stock Python 3 with the standard library. The active_pro runtime module (which provides wait_for, append, all condition factories, etc.) is on the Python path whether you use bundled or custom Python, the application adds its own python_runtime folder to PYTHONPATH for the duration of the run.
The application also adds the decoder folder itself as the subprocess's working directory, so import my_helpers from inside a decoder will pick up my_helpers.py sitting next to it in the decoder folder.
Persistence
A port's Custom Decoder configuration consists of five pieces:
- The display name (filename without
.py). - The
DECODER_MYNAMEvalue from the script's first line. - The full script text.
- The parameter values, as JSON.
- An enabled flag.
All five are written into every .active capture file you save, and read back when you open it. Reopening a saved capture re-runs the decoder against the captured data, so you see exactly the same decoded annotations the original user saw. Shipping a .active file to a colleague does not require shipping the .py file separately.
The .active file is the source of truth for what runs when you open that capture. If you edit the bundled UART_Decoder.py after saving a capture that used the old version, opening the old capture still runs the old version (because that text is what was saved into the file).
Authoring a Custom Decoder
File anatomy
A decoder is a single .py file with three things, in this order:
- A header block of
#-comments at the very top, ending no later than the first 100 lines or the first non-blank, non-comment line, whichever comes first. - The import of the runtime symbols you want to use.
- A
def decode(params):generator function.
Here is the minimum viable decoder:
# DECODER_MYNAME = "Hello"
#
# A decoder that emits one annotation at the start of capture.
from active_pro import append, wait_time
def decode(params):
m = yield from wait_time(0)
if m is None:
return
append(m.t, m.t, channel=0, text="Hello, world")
Save it as Hello.py in the decoder folder, pick it on port A, capture, and you will see "Hello, world" on the first output row of port A.
The header block
DECODER_MYNAME (required)
The very first thing the parser looks for is a comment of the form:
# DECODER_MYNAME = "My Display Name"
Without this line, the file is not treated as a decoder. It will not appear in the picker. The string between the quotes is the human name used in the waveform display ribbon (e.g. MyName A) and in the .aft AI export device-source map.
It does not need to match the filename. The filename is what is used internally; this string is what humans see.
Description (optional)
The first free-form comment line in the header, that is, the first # line that is not DECODER_MYNAME and not a PARAM: line, becomes the decoder's description in the picker. Keep it to one sentence; the picker only shows the first line.
PARAM: lines (optional)
A PARAM: line declares one user-editable parameter and contributes one row to the gear dialog. The format is five pipe-separated fields:
# PARAM: <key> | <Label> | <type> | <options> | <default>
- key - the dict key your script reads via
params["key"]. Letters, digits, and underscores. - Label - the human label shown in the dialog. Spaces are fine.
- type - one of
select,int,float,bool,string,digital_channel,analog_channel. See "Configuring Parameters" above. - options - interpretation depends on type:
select- comma-separated list of dropdown choices.int/float-min,maxnumeric range, or blank for unconstrained.bool/string/digital_channel/analog_channel- leave blank.- default - the initial value. Compared against the choices for
select; parsed as a number forint/float/digital_channel/analog_channel;trueorfalseforbool; literal string forstring. The stringparams["key"]your script reads is always a string - even for ints and bools. Cast it yourself.
The fields are split on the literal pipe character |, so do not use a pipe inside any of them.
Examples taken from the bundled decoders:
# PARAM: baud_rate | Baud Rate | select | 9600,19200,38400,57600,115200,230400 | 115200
# PARAM: data_bits | Data Bits | select | 5,6,7,8 | 8
# PARAM: parity | Parity | select | None,Even,Odd | None
# PARAM: logic_channel | Logic Channel | digital_channel | |
# PARAM: threshold | Threshold (V) | float | 0.0,5.0 | 2.5
# PARAM: invert | Invert Polarity | bool | | false
A malformed PARAM: line (missing fields) is logged and ignored, the picker still shows the decoder, but that parameter will not appear in the gear dialog.
Header ends
Once a non-blank, non-comment line is encountered, the parser stops reading metadata. So put your imports and code after the header block. The first 100 lines are scanned in any case.
The runtime API
Everything your script needs comes from the active_pro module:
from active_pro import (
append,
wait_for, wait_time,
rising_edge, falling_edge, high, low,
voltage_rises, voltage_falls,
voltage_above, voltage_below, voltage_between,
all_of, any_of, not_,
)
You can also from active_pro import wait_for_edge if you prefer the shorthand below.
The mental model
The decoder maintains a single notion of current time, cursored independently on the logic buffer and the analog buffer. Every wait operation advances time. There are exactly two:
m = yield from wait_for(condition)- advance untilconditionis true.m = yield from wait_time(seconds)- advance by a fixed amount of time.
Both return a Moment - a snapshot of every signal at the new current time:
m.t- timestamp in seconds (float).m.d[0..7]- tuple of 8 ints (0 or 1), the digital channels at this moment.m.bits- the same 8 channels packed as a byte (m.d[0]is bit 0).m.a[0..7]- list of 8 floats, the analog voltages at this moment. On a Pro pod, onlym.a[0..3]are meaningful; on Ultra, all eight are.
Both calls return None when the captured buffer is exhausted (or a timeout is hit, for wait_for(condition, timeout=...)). Always check for None before using m. If you forget and dereference a None Moment, your decoder will crash and the modal error dialog will tell you the line number.
Conditions
Conditions are built from factory functions and tested against Moments. Use them as the first argument to wait_for. They are stateful, for example, rising_edge(0) remembers the previous bit so it can detect the transition, so build a fresh one each time you call wait_for (the bundled decoders do this implicitly by calling the factory inline).
Digital conditions
rising_edge(channel)- fires the first moment afterm.d[channel]transitions from 0 to 1.falling_edge(channel)- fires whenm.d[channel]transitions from 1 to 0.high(channel)- fires wheneverm.d[channel] == 1. No transition required.low(channel)- fires wheneverm.d[channel] == 0.
channel is 0-7.
Analog conditions
voltage_rises(channel, threshold, hysteresis=0.0)- fires on the rising threshold crossing. With hysteresis = 0, fires every time the voltage rises throughthreshold. With hysteresis > 0, the voltage must first fall to or below(threshold - hysteresis)before the next rise can fire, the trigger level itself stays atthreshold. This is asymmetric re-arm; useful for noisy signals.voltage_falls(channel, threshold, hysteresis=0.0)- same but on the falling crossing.voltage_above(channel, threshold)- fires whenever the voltage is currently strictly abovethreshold. No crossing required.voltage_below(channel, threshold)- fires whenever currently strictly belowthreshold.voltage_between(channel, low, high)- fires wheneverlow <= voltage <= high.
For analog conditions, channel is 0-indexed (0 is the first analog channel), even though analog_channel PARAM values are 1-indexed. Subtract 1 when you pull a channel number out of params:
analog_ch = int(params.get("probe_ch", "1")) - 1
m = yield from wait_for(voltage_rises(analog_ch, 2.5, hysteresis=0.1))
Combinators
all_of(c1, c2, ...)- AND. Every sub-condition must be true at the same moment. The combinator does not short-circuit, so all child conditions still see every sample (important for edge-detector state machines that need to track the previous level).any_of(c1, c2, ...)- OR.not_(condition)- NOT. Be careful:not_(rising_edge(0))fires on almost every sample, because almost no sample is an instantaneous rising edge. NOT is most useful inside anall_ofto exclude one condition.
You can nest them freely:
m = yield from wait_for(all_of(
falling_edge(0),
any_of(
voltage_above(0, 4.5),
voltage_below(1, 0.3),
),
not_(high(7)),
))
A combinator's primary buffer (logic vs. analog) is decided by its children: a combinator is "analog-driven" only if every sub-condition is analog. Mixing any digital condition into the tree makes the combinator logic-driven, so it gets stepped at the logic sample rate (which is much faster than the analog sample rate). The other buffer's values at that moment are filled in via sample-and-hold lookup.
wait_for_edge shorthand
For the common case of waiting for an edge on a digital channel:
m = yield from wait_for_edge(0, 'rising') # same as wait_for(rising_edge(0))
m = yield from wait_for_edge(0, 'falling') # same as wait_for(falling_edge(0))
m = yield from wait_for_edge(0, 'any') # rising OR falling
m = yield from wait_for_edge(0, 'rising', timeout=1e-3)
The timeout argument is in seconds. If the timeout elapses with no edge, wait_for_edge returns None.
Emitting annotations: append
append(t_start, t_end, channel,
text, color=0, sample_type=0, data=0)
- t_start, t_end - start and end timestamps of the annotation in seconds. For a point event use the same value for both.
- channel - output row index, 0-7, within this decoder's slot. The application assigns each decoder a base of 8 output channels (slot A gets the first 8, B the next 8, and so on), and your
channelargument is added to that base. You always pass 0-7. - text - UTF-8 string up to 120 bytes. Longer strings are truncated.
- color - 32-bit ARGB integer, e.g.
0xFFAACCFFfor a soft blue.0means "use the theme default for this row". - sample_type - controls how an attached PacketPresenter sees the byte. Use one of:
0=SAMPLE_DATA- normal data byte.1=SAMPLE_DATA_ALT- alternate data channel (e.g. a parallel ASCII rendering).2=SAMPLE_PACKET_START- the PacketPresenter sees this as the first byte of a new packet.3=SAMPLE_PACKET_END- closes the current packet.- data - 32-bit unsigned payload available to PacketPresenter scripts and to value-mode trigger conditions on the same channel.
The bundled decoder_template.py defines the four SAMPLE_* constants at the top so your decoder can use the names directly:
SAMPLE_DATA = 0
SAMPLE_DATA_ALT = 1
SAMPLE_PACKET_START = 2
SAMPLE_PACKET_END = 3
The decode function
decode(params) must be a generator function (it must use yield from at least once) and must accept a single argument, params, which is a dict of all PARAM values as strings.
A typical structure looks like:
def decode(params):
ch = int(params.get("logic_channel", "0"))
baud = int(params.get("baud_rate", "115200"))
bit_t = 1.0 / baud
while True:
# Wait for the start of a frame.
start = yield from wait_for(falling_edge(ch))
if start is None:
return
# Sample N bits at bit_t spacing.
byte_val = 0
for i in range(8):
m = yield from wait_time(bit_t)
if m is None:
return
byte_val |= (m.d[ch] << i)
# Emit one annotation per byte.
append(start.t, m.t, 0, f"0x{byte_val:02X}",
sample_type=2, data=byte_val) # SAMPLE_PACKET_START
append(start.t, m.t, 1, chr(byte_val) if 32 <= byte_val < 127 else ".",
sample_type=3, data=byte_val) # SAMPLE_PACKET_END
return from decode ends the run cleanly. Falling off the bottom does the same.
Useful helpers from the standard library
You may import anything from the Python standard library inside your decoder. import sys for sys.stderr, import struct for binary unpacking, import json if you need to parse a fixture, import math for math.copysign, and so on. The first time you import a module the subprocess pays a small startup cost; subsequent decoder runs within the same capture share the cost.
You may also import sibling .py files from your decoder folder (e.g. import my_protocol_helpers), the decoder folder is the subprocess's working directory.
Performance notes
- The decoder walks the captured buffer linearly, advancing the cursor on every
wait_forandwait_time. There is no random-access cost, the data is memory-mapped read-only. all_ofdoes not short-circuit. Sub-conditions are evaluated every sample so that edge-detector children stay in sync with the actual signal history.- Analog conditions step at the analog sample rate; digital conditions step at the logic sub-sample rate (~500 MS/s on Pro, sub-nanosecond on Ultra). A mixed combinator runs at the logic rate. If you only need analog rate, keep the combinator's children all-analog.
- Big text strings are capped at 120 bytes per annotation. Splitting one annotation across multiple
appendcalls is fine; emitting a 4-MB annotation is not.
What you cannot do
- You cannot influence what was captured. The buffer is finalized before the decoder runs.
- You cannot communicate between decoders. Each runs in its own subprocess. Use four separate ports if you want four decoders; coordinate via the captured channels.
- You cannot write to
stdout. Stderr is captured by the host but not currently surfaced in the UI for the user to read, the only places decoder text becomes visible are the per-decoder status-bar progress line and, on failure, the modal traceback dialog. - You cannot import GUI libraries (Qt, Tk, etc.). Decoders are headless subprocesses.
- You cannot prompt for input. The script runs to completion or fails; there is no interactive step.
Troubleshooting
"Custom Decoder 'X' could not run" with a Python traceback
The script raised an unhandled exception before, during, or while shutting down. The dialog shows the last lines of the script's stderr, the traceback's bottom frame is the line your decoder failed on. Open the script with the pencil button, fix it, click Save, then APPLY CHANGES.
Decoder appears in the folder but not in the picker
The most common cause is a missing or malformed DECODER_MYNAME line. The line must look exactly like:
# DECODER_MYNAME = "Some Name"
The #, a space, the literal text DECODER_MYNAME, an =, and a double-quoted string. The picker scans only the first 100 lines of the file and stops at the first non-comment line, so make sure the line is in the header block.
Decoder runs but emits no annotations
Likely causes, in order of frequency:
- The channel parameter is pointing at a digital channel that was not capturing data. The PARAM type
digital_channelforces the channel on; aselectorintparameter does not. Switch the PARAM type, or enable the channel manually on the Inputs tab. - The condition you are waiting for never becomes true. Add an
append(m.t, m.t, 0, f"loop t={m.t}")call inside your loop, the annotation rows it produces are user-visible, and the status-bar "records" counter will increment so you can confirm the loop is executing. - You are dereferencing a
NoneMoment, which would normally crash, but you may have wrapped the wait in atry/exceptthat silently returns. Take thetry/exceptoff.
Decoder hangs
The script has an infinite loop with no advancing wait inside. Custom Decoders rely entirely on wait_for/wait_time to make progress through the capture buffer; a while True: loop with no wait inside it never returns control. The CAPTURE button cancels the run.
"Decoder script did not signal ready within 30 seconds"
The script crashed during import (so the runtime never got to print ready), or the chosen Python interpreter is broken. The dialog includes the script's stderr, read it from the bottom up. The most common cause is a SyntaxError somewhere in the script, which Python reports with line and column.
Channel conflict warning every time you save
Two ports are using the same channel. The application does not auto-fix it, you have to decide which port owns the channel. Open the gear dialog on one of the ports and change its digital_channel / analog_channel parameter to a free channel.
Quick Reference
File header skeleton
# DECODER_MYNAME = "My Decoder"
#
# One-sentence description goes here.
#
# PARAM: my_key | My Label | digital_channel | | 0
# PARAM: my_int | My Int | int | 0,255 | 0
# PARAM: my_sel | My Sel | select | low,medium,high | medium
Runtime imports
from active_pro import (
append,
wait_for, wait_time, wait_for_edge,
rising_edge, falling_edge, high, low,
voltage_rises, voltage_falls,
voltage_above, voltage_below, voltage_between,
all_of, any_of, not_,
)
Wait operations
m = yield from wait_for(condition)- wait until condition fires.m = yield from wait_for(condition, timeout=seconds)- wait until condition fires ortimeoutseconds elapse.m = yield from wait_time(seconds)- advance byseconds.m = yield from wait_for_edge(channel, edge='rising', timeout=None)- shorthand.- All return
Noneon end-of-capture (or timeout); check before using.
Moment fields
m.t- float seconds.m.d[0..7]- tuple of 0/1.m.bits- same packed as a byte.m.a[0..7]- list of floats (volts).
Output
append(t_start, t_end, channel, text, color=0, sample_type=0, data=0)channelis 0-7 within this decoder's slot.sample_type: 0 = DATA, 1 = DATA_ALT, 2 = PACKET_START, 3 = PACKET_END.
Parameter access
params["key"]returns a string.- Cast with
int(...),float(...), orlower() == "true"for booleans. - For
analog_channelparameters, subtract 1:int(params["ch"]) - 1.