A Qt6 desktop app that emulates a NanoVNA on a real Linux pseudo-terminal
(/dev/pts/N), so AntScopeZ (or any other NanoVNA client*) can be pointed at
it exactly as if it were real hardware. Built to develop/test AntScopeZ's
NanoVNA protocol handling without needing physical hardware plugged in.
- Client software must support connecting to a non-USB local pty device.
- Opens a fresh pty pair on launch (
openpty()), and symlinks the slave device node to the stable path/tmp/nanovna-emulatorso you don't have to chase a new/dev/pts/Nnumber every run. - Speaks the classic NanoVNA / NanoVNA-H / NanoVNA-H4 ASCII shell protocol
(
info,scan+ capability probe, both ascii and binaryscanreply framing, and the legacysweep/frequencies/data 0/data 1fallback) -- compared line-for-line against AntScopeZ's own client implementation (analyzer/nanovna_analyzer.cpp). - Answers S-parameter data per point on demand, honoring whatever frequency
span and point count a single request asks for -- from a formula, or
interpolated from a Touchstone (
.s2p) file, depending on the scenario; see "Scenarios" below. - Ships
scenarios/generate_scenarios.py, a standalone script that derives every bundled.s2pfile from closed-form physical models (filter transfer functions, component equivalent circuits) instead of hand-typed numbers -- rerun it any time to regenerate them exactly, or copy a function as a template for a new one. See "Generating scenario files" below. - Shows both directions of serial traffic live, color-coded, with timestamps.
Every device this project models (classic NanoVNA/H/H4, and eventually NanoVNA V2/SAA-2/LiteVNA64) reports exactly S11+S21 on the wire -- none of them have a full 2-port (S12/S22) wire format. This project emulates real hardware behavior, so it doesn't invent one either.
No ini-file editing needed any more. As of 2026-09-03, AntScopeZ's own
Connect Analyzer dialog (the COM tab) is directly aware of this
emulator: while it's running, the dialog detects the /tmp/nanovna-emulator
symlink and lists it as two ordinary, clickable rows right alongside any
real hardware:
- NanoVNA (dev emulator) -- matches this emulator's classic ASCII-shell device profile.
- NanoVNA V2 (dev emulator) -- matches this emulator's binary V2/ LiteVNA64 device profile.
So: launch this emulator first (it (re)creates the symlink every run), then in AntScopeZ open Connect Analyzer and just click whichever row matches this emulator's own "Device:" combo (top of its window) -- the pty itself has no VID/PID for AntScopeZ to detect which protocol is actually on the other end, so that's the one thing you still have to keep in sync by hand. That's the whole workflow now.
Hand-editing the ini's [Connection] group (id=, type= -- 2 for
classic NanoVNA, 5 for NanoVNA V2, per devinfo/redeviceinfo.h's
InterfaceType enum -- and name=) is only still needed for a port Connect
Analyzer's detection genuinely can't see: this emulator's own path is
hardcoded (kSymlinkPath in src/MainWindow.cpp, no way to run a second
instance on a different path today), and the dialog otherwise only
auto-populates real serial ports matching a known NanoVNA/NanoVNA-V2
VID/PID, plus BLE. Anything else -- a manually created socat/pty link
that isn't this emulator's own symlink, or real hardware whose VID/PID
isn't one of the ones it's looking for -- falls outside all of that and
still needs the manual ini entry. The dialog's device table is otherwise
selection-only from whatever it actually detects; double-clicking a row
doesn't open an editable cell, it immediately connects using that row's
existing text.
NanoVNA V2/LiteVNA64 support on the AntScopeZ side is new (2026-09-03) and has been run live against this emulator's own binary profile, but as of this writing hasn't yet been confirmed against real V2/SAA-2/LiteVNA64 hardware -- testing it here is exactly the point.
AntScopeZ connected to the emulator above (same crystal_10mhz.s2p
scenario), showing the S21 dB/phase curves the emulator computed --
series resonance at 10.000 MHz, parallel antiresonance just above it,
exactly where the emulator's own setup hint said they'd be:
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Debug
cmake --build .
./NanoVnaEmulator
Requires Qt6 Widgets. Linux only (uses openpty()/pty.h).
- NanoVNA / H / H4 (classic ASCII shell) -- done, compared line-for-line
against AntScopeZ's own client (
analyzer/nanovna_analyzer.cpp). AntScopeZ can connect to this today. - NanoVNA V2 / SAA-2 / LiteVNA64 (binary register/FIFO protocol) -- done,
compared against two independent real client implementations: NanoVNASaver
(
Hardware/NanoVNA_V2.py,Hardware/LiteVNA64.py) and libxavna (github.com/nanovna-v2/NanoVNA-QT, read for cross-reference only -- GPL-2 -only, not incorporated). AntScopeZ gained its own client for this protocol (NanovnaV2Analyzer, 2026-09-03) -- both sides now speak it for real, not just forward groundwork.
Selectable live from "Response data" regardless of which device profile is
active. A ScenarioCatalog owns every one and hides where each came from
from the rest of the app -- MainWindow just renders whatever list it's
handed, the same "don't leak format detail past this layer" discipline
DeviceProfile already applies to the wire protocols themselves. Picking a
scenario shows a hint line underneath the combo (see "Setup hints and range
warnings" below).
Two kinds of ScenarioProvider, chosen per scenario for a specific reason,
not by style preference:
- Synthetic (formula-based C++ class) -- for anything that has to stay correct at an arbitrary requested frequency/span/point-count, because a finite pre-baked file either can't cover that (a cal standard or TDR scan can be asked for nearly any span) or would need to be absurdly dense to try. Also covers scenarios that deliberately reshape themselves to whatever span was requested (the SWR dip always sits at your sweep's center) -- something no fixed-frequency file can do at all.
- Touchstone file (
.s2pin./scenarios/) -- for a specific real (or realistic) device whose interesting behavior lives in one bounded, already-known frequency window: a filter's passband, a component's self-resonant frequency, a crystal's motional response. "What's this 2 m bandpass filter doing at 5 GHz" isn't a real question, so the file's finite range is a feature, not a limitation.
Synthetic scenarios:
- SWR 2.5-1.1-2.5 dip -- range-relative (see above); only accurate for a single, non-stitched sweep. See "Known limitations" below.
- Short / Open / Load (near-ideal) -- one-port cal-standard-like
terminations, deliberately not exactly
|Gamma|=1or0so SWR/markers stay finite. Imperfection (phase drift) is a function of absolute frequency, matching how a real standard's fringing capacitance/lead inductance actually behaves -- fixed 2026-09-04 after these were found to derive it from requested-range-relative position instead, which happened to look fine for a quick SWR check but was subtly wrong for TDR (and for stitched sweeps). - TDR: Open/Short fault @ N ft (VF 0.66) (three instances) -- a matched
feedline out to a single open- or short-circuit fault at a fixed physical
distance, the textbook TDR test case.
Tools > TDR Measurementin AntScopeZ just runs a wider/denser S11 sweep and does the time-domain IFFT client-side -- there's no separate TDR wire protocol, so shapingS11(f)correctly here is the whole job.
Touchstone-file scenarios (./scenarios/*.s2p, auto-discovered at
startup -- drop in another .s2p file and it just appears, no code
changes needed): cable_lossy.s2p (100 ft lossy feedline -- replaces the
old range-relative ThruScenario, and being absolute-frequency-based is
immune to the stitching artifact below, for free), bpf_2m.s2p (2 m band
bandpass filter), lpf_hf.s2p (HF TX low-pass filter, 30 MHz cutoff),
notch_filter.s2p (27.5 MHz trap), capacitor_ceramic.s2p and
inductor_rf_choke.s2p (series-thru bench measurements of a small RF cap
and an RF choke, each showing its self-resonant frequency -- a peak for
the cap, a notch for the inductor, per the underlying physics), lc_tank.s2p
(series-resonant LC network, narrowband peak), and crystal_10mhz.s2p (a
10.000000 MHz crystal's motional response -- a sharp pass peak at series
resonance immediately followed by a deep notch at the nearby parallel
antiresonance, deliberately extreme: see "Setup hints" below for why).
"Load Touchstone file...", always the last combo entry, opens a file
picker for any other .s2p file (a real capture, or one you hand-craft) --
added under a "Custom" group for the rest of that session.
All 8 bundled .s2p files above come from one script,
scenarios/generate_scenarios.py -- plain-stdlib Python (cmath/math
only, no dependencies), no C++ build step involved:
python3 scenarios/generate_scenarios.py
Each scenario is a short, self-contained function computing S11/S21 from an
actual closed-form model -- a filter's Butterworth transfer function, a
component's equivalent-circuit impedance fed through the series-thru
formula, a crystal's motional admittance -- rather than numbers typed in by
hand, so the physics is exact and easy to re-derive or retune (change a
component value, rerun, done) instead of hand-editing a data table. It also
prints a summary of what it actually generated -- each file's real peak/dip
frequencies and dB depths, computed from its own output, not guessed -- so
you can sanity-check a change immediately without opening the emulator.
Adding a 9th scenario is: write one more function in the same shape, call
it from main(), rerun the script -- ScenarioCatalog picks up any .s2p
it finds with zero code changes on the C++ side.
This is the same technique that caught and fixed three real modeling bugs
during development (a capacitor value that made its own demo flat/boring,
an exact-zero sample at a notch center that would've handed AntScopeZ a
-inf dB point, and a crystal antiresonance that fell outside its own
file's sweep window) -- computed and cross-checked programmatically rather
than trusted from hand arithmetic.
Every ScenarioProvider can report two things, shown/logged automatically
regardless of which kind it is:
setupHint()-- a static description shown under the combo the moment a scenario is selected. For a Touchstone scenario this combines any!-comment lines from the file itself with an always-accurate, code-computed line (point count, actual frequency range, so it can't go stale even if the file's own comments do) -- e.g. telling you the crystal file only covers 9.990-10.030 MHz, so a normal wide antenna sweep will blow straight past its resonance. Synthetic scenarios use it for real behavioral caveats instead (e.g. the SWR dip's range-relative note above).rangeWarning(start, stop, points)-- checked against every actual incoming scan request (not just once at selection time); returns a warning the moment that specific request can't be answered accurately, appended right onto that request's own status-bar line so you see it happening in real time, not just as a static note you might not have read. Two different scenario kinds use this for two different problems: a Touchstone scenario warns when a request's span exceeds its file's coverage (e.g.Scan sweep: 401 pts (ascii) -- ⚠ requested 1.000-30.000 MHz exceeds loaded file's 9.990-10.030 MHz -- clamping to file edges outside that range), whileCableFaultScenariowarns when a request's bandwidth/point-count combination would alias its fault to the wrong apparent distance (e.g.... ⚠ this fault is 30 ft (9.14 m) out, but this request's own unambiguous TDR range is only 4.50 m (201 pts over 4400.000 MHz) -- the reflection will alias to ~0.15 m instead) -- see "Known limitations" below for the physics behind that one.
One mode so far: "Simulate unresponsive device" drops every incoming byte without replying -- good for exercising AntScopeZ's scan-timeout watchdog and its "device present but busy/unreachable" handling. More modes (binary header corruption, partial replies, dropped prompts) are natural follow-ons whenever a specific AntScopeZ robustness path needs exercising.
Symptom: request a scan with more points than AntScopeZ's Settings >
General > "Analyzer maximum number of points" (g_analyzerMaxPoints,
default 1000), and the SWR dip scenario comes back looking like a repeating
sawtooth -- dip, ramp up, sharp drop, dip, ramp up, sharp drop -- once per
segment, instead of one smooth curve across the whole requested range. It's
the only scenario left with this problem: Short/Open/Load and the TDR
cable-fault scenarios compute from absolute frequency (see "Scenarios"
above), and every Touchstone-file scenario is inherently absolute-frequency
too -- none of them can exhibit this.
Why: when the requested point count exceeds that setting, AntScopeZ
splits the sweep into several smaller sequential requests ("stitching") and
reassembles the replies into one continuous dataset on its end -- that part
works correctly, no data is lost. But this emulator answers each request
as it arrives, computing that request's response data based on that
request's own frequency range -- it has no way to know, at the moment it
answers the first segment, how many more segments are coming or what the
true final combined range will be. Each segment's curve gets shaped
independently, using only what was known at the time, so the SWR dip
(whose shape depends on "where's the middle of what was asked for")
produces a different dip per segment instead of one continuous curve across
the true overall sweep. This was tried as a bug fix (ContiguousRangeTracker,
since removed from SwrDipScenario::beginSweep()) and confirmed
structurally unfixable within this design: a synchronous request/response
protocol with no lookahead cannot know the future. A real fix would mean
computing the dip's shape from absolute frequency instead -- a real
behavior change (the dip would sit at a fixed frequency instead of always
centering on whatever you sweep), deliberately not made without confirming
that trade-off first.
Workaround: set Settings > General > "Analyzer maximum number of points" to a value >= the number of points in your scan. This keeps the whole sweep as a single, non-stitched request, so the emulator computes the curve once, correctly, across the true full range -- exactly the behavior you'd expect. Or just pick a different scenario -- everything except the SWR dip is unaffected regardless of stitching.
Symptom: e.g. "TDR: Short fault @ 30 ft (VF 0.66)" (30 ft = 9.14 m) shows its impulse right up near the origin (sub-1 m) instead of out at 9 m, with nothing at all where the real fault should be.
Why: this is real frequency-domain-TDR aliasing, verified independently
(a from-scratch numpy IFFT reconstruction against CableFaultScenario's own
S11 model places the peak at exactly 9.146 m given adequate bandwidth/point
count) -- not a bug in the emulator's math, and not something real hardware
is immune to either. A frequency sweep's unambiguous one-way TDR range is
c * VF * (points-1) / (2 * bandwidth_Hz) -- AntScopeZ's own formula, see
calcTdrEstimateRaw() in measurements_tdr.cpp. Push bandwidth up (e.g. the
NanoVNA V2/SAA-2/LiteVNA64 binary profile's full 4.4 GHz) while leaving
points at the TDR tool's 200-point minimum, and the unambiguous range drops
to well under 5 m -- a 9 m-out fault then aliases back to an apparent
~0.15 m, matching this symptom almost exactly. More bandwidth buys distance
resolution; only more points buy back unambiguous range at a given
bandwidth.
Workaround: raise the TDR scan's point count (up to TDR_MAXPOINTS,
1000) and/or lower its top frequency until CableFaultScenario's own live
warning (see "Setup hints and range warnings" above) goes quiet -- it
computes this exact threshold per request and tells you the aliased
distance it's about to produce.
- Session capture back out to Touchstone/raw logs (the reverse of file replay above).
- Installed/packaged path resolution for
./scenarios/-- it's currently found by walking a short list of candidate paths relative to the running binary (seeScenarioCatalog::bundledScenariosDir()), which covers every way this dev tool is run today (plainbuild/, Qt Creator's ownbuild/<kit>/subdirectory, or launched by hand from the repo root) but not a real installed layout.

