Doppler CLI
doppler-cli is a pipeline orchestrator for Doppler signal processing
chains. It lets you wire sources, DSP blocks, and sinks together with
a single command — or a declarative compose file — and manages the
lifetime of every process.
Install:
pip install doppler-cli
Quick start
# Scaffold a named chain and start it
doppler compose init tone specan --name my-chain
doppler compose up my-chain
# Or use a hex ID (auto-generated without --name)
doppler compose init tone fir specan
doppler compose up # defaults to most recently created
# Check what's running
doppler ps
# Tear it down
doppler stop my-chain
Architecture
Each block in a pipeline runs as an independent OS process. Blocks
exchange IQ samples over ZMQ PUSH/PULL sockets. The compose runner
assigns ports, spawns processes, and tracks their state in
~/.doppler/chains/.
flowchart TB
subgraph Runner ["doppler compose up"]
CFG["compose file\n~/.doppler/chains/a3f7c2.yml"]
STATE["chain state\n~/.doppler/chains/a3f7c2.json"]
end
subgraph Pipeline ["Signal Chain"]
direction TB
SRC["Source\n(e.g. tone)"]
DSP["DSP Block\n(e.g. fir)"]
SINK["Sink\n(e.g. specan)"]
SRC -- "PUSH tcp://127.0.0.1:5600" --> DSP
DSP -- "PUSH tcp://127.0.0.1:5601" --> SINK
end
Runner -- "spawns + tracks" --> Pipeline
Socket convention: every block binds its output and connects its input. Sources bind only. Sinks connect only.
source → binds PUSH :5600
fir → connects PULL :5600, binds PUSH :5601
specan → connects PULL :5601
Compose file
doppler compose init <BLOCKS...> scaffolds a fully-resolved compose
file with all defaults and auto-assigned ports filled in.
id: a3f7c2
source:
type: tone
sample_rate: 2048000.0
center_freq: 0.0
tone_freq: 100000.0
tone_power: -20.0
noise_floor: -90.0
port: 5600 # output port this block binds
chain:
- fir:
taps: []
port: 5601 # output port this block binds
sink:
type: specan
mode: web # terminal mode requires a foreground TTY; use web in pipelines
center: 0.0
span: null
rbw: null
level: null
web_port: 8080
Ports default to auto-assigned from the range 5600–5700. Specify
them explicitly to pin a chain to fixed addresses.
Commands
Chain lifecycle
| Command | Description |
|---|---|
doppler ps |
List all running chains with status and uptime |
doppler stop <ID> |
Graceful shutdown (SIGTERM all block processes) |
doppler kill <ID> |
Immediate shutdown (SIGKILL all block processes) |
doppler inspect <ID> |
Print resolved config, PIDs, and port assignments |
doppler logs <ID> [--block NAME] |
Stream stdout/stderr from a chain or block |
Compose
| Command | Description |
|---|---|
doppler compose init <BLOCKS...> |
Scaffold a compose file with defaults |
doppler compose init <BLOCKS...> --name NAME |
Give the chain a human-readable name |
doppler compose init <BLOCKS...> --out FILE |
Write to a specific path |
doppler compose up [FILE\|NAME] |
Spawn all blocks described in FILE (defaults to latest) |
doppler compose down <ID\|NAME> |
Stop a running chain (alias for stop) |
Block catalog
tone — synthetic source
Generates a calibrated complex tone plus AWGN. Good for validating filter frequency response before connecting a real IQ source.
| Field | Default | Description |
|---|---|---|
sample_rate |
2048000.0 |
Output sample rate (Hz) |
center_freq |
0.0 |
Nominal center frequency (Hz, metadata) |
tone_freq |
100000.0 |
Tone offset from DC (Hz) |
tone_power |
-20.0 |
Tone power (dBm) |
noise_floor |
-90.0 |
AWGN floor (dBm) |
fir — FIR filter
Applies a real FIR filter to the IQ stream. Design taps with
doppler.polyphase or any standard tool.
| Field | Default | Description |
|---|---|---|
taps |
[] |
Filter coefficients (passthrough if empty) |
Example — design a 101-tap lowpass and use it in a chain:
from doppler.polyphase import design_lowpass
taps = design_lowpass(cutoff=0.1, numtaps=101).tolist()
Then set taps in the compose file, or patch it:
doppler compose init tone fir specan --out chain.yml
# edit chain.yml: set fir.taps: [...]
doppler compose up chain.yml
specan — spectrum analyzer sink
Displays the spectrum of the incoming IQ stream. Connects to the
doppler-specan terminal or web UI.
| Field | Default | Description |
|---|---|---|
mode |
"terminal" |
"terminal" or "web" |
center |
0.0 |
Center frequency (Hz) |
span |
null |
Display span (Hz); defaults to full bandwidth |
rbw |
null |
Resolution bandwidth (Hz) |
level |
null |
Reference level, top of display (dBm) |
web_port |
8080 |
HTTP port for web mode |
Typical workflows
Measure a filter's frequency response
doppler compose init tone fir specan --out filter_test.yml
# Edit filter_test.yml:
# fir.taps: [<your taps>]
# specan.span: 500000
doppler compose up filter_test.yml
flowchart TB
T["tone\n−20 dBm @ 100 kHz"]
F["fir\nlowpass taps"]
S["specan\n500 kHz span"]
T --> F --> S
Connect a real IQ source
Replace tone with any ZMQ publisher emitting doppler-framed IQ:
source:
type: socket
address: tcp://192.168.1.10:5555
!!! note
A socket source block is planned for a future release. In the
meantime, run doppler-specan --source socket --address <addr>
directly to attach the spectrum analyzer to an existing publisher.
State files
Running chain state is persisted in ~/.doppler/chains/:
~/.doppler/chains/
a3f7c2.yml # compose file (copy written by init)
a3f7c2.json # live state: PIDs, ports, start time
doppler stop and doppler kill remove the .json file on
completion. Orphaned .json files from crashed chains can be removed
manually or with doppler stop <ID> (gracefully handles dead PIDs).
Creating a new block
!!! tip "No Python required" The fastest way to add a custom block is a dopplerfile — a small YAML file that registers any script as a pipeline block with zero Python. See the Dopplerfile guide.
The following shows how to add a built-in block in Python. Use this
path when you need full control over config validation, complex arg
building, or want to ship the block as part of doppler-cli itself.
Here is a minimal example — a noise source that emits pure AWGN:
1. Config schema — declare fields with defaults using pydantic:
# python/cli/doppler_cli/blocks/noise.py
from doppler_cli.blocks import Block, BlockConfig, register
class NoiseConfig(BlockConfig):
sample_rate: float = 2.048e6
noise_floor: float = -60.0
@register
class NoiseBlock(Block):
name = "noise"
Config = NoiseConfig
role = "source" # "source" | "chain" | "sink"
def command(self, config, input_addr, output_addr):
assert output_addr is not None
return [
"doppler-noise",
"--bind", output_addr,
"--fs", str(config.sample_rate),
"--noise-floor", str(config.noise_floor),
]
2. Register it — import the module in __main__.py:
import doppler_cli.blocks.noise # noqa: F401
3. Entry point — add a doppler-noise script in pyproject.toml:
[project.scripts]
doppler-noise = "doppler_cli.noise_source:main"
4. Startup log — every block entry point must print a health line
on startup so doppler logs confirms what's running:
from datetime import datetime, timezone
def _log(msg):
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
print(f"[{ts}] {msg}", flush=True)
# In main(), before the processing loop:
_log(f"doppler-noise started — bind={args.bind} fs={args.fs:.0f}")
5. Use it:
doppler compose init noise specan --name noise-test
doppler compose up noise-test
doppler logs noise-test
# [2026-04-01T10:00:00Z] doppler-noise started — bind=tcp://127.0.0.1:5600 fs=2048000
# [2026-04-01T10:00:00Z] doppler-specan started — mode=web source=pull address=tcp://127.0.0.1:5600
Port allocation
Ports are auto-assigned from the range 5600–5700 by scanning
existing state files for in-use ports. The base port is configurable:
# ~/.doppler/config.yml
base_port: 5700
To pin ports explicitly, set port: on the source and each chain
block in the compose file. Pinned ports are used as-is; no allocation
is performed.