Skip to content

Repository files navigation

NanoVNA Emulator

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.

NanoVNA Emulator emulating a LiteVNA64, serving the bundled crystal_10mhz.s2p scenario, with live serial traffic logged below

What it does

  • Opens a fresh pty pair on launch (openpty()), and symlinks the slave device node to the stable path /tmp/nanovna-emulator so you don't have to chase a new /dev/pts/N number every run.
  • Speaks the classic NanoVNA / NanoVNA-H / NanoVNA-H4 ASCII shell protocol (info, scan + capability probe, both ascii and binary scan reply framing, and the legacy sweep/frequencies/data 0/data 1 fallback) -- 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 .s2p file 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.

What it deliberately does NOT do

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.

Pointing AntScopeZ at it

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:

AntScopeZ v2.2.5 connected to the NanoVNA Emulator as a LiteVNA64, plotting S21 dB and phase for the crystal_10mhz.s2p scenario

Building

mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Debug
cmake --build .
./NanoVnaEmulator

Requires Qt6 Widgets. Linux only (uses openpty()/pty.h).

Device profiles

  • 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.

Scenarios

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).

Synthetic vs. Touchstone file

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 (.s2p in ./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|=1 or 0 so 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 Measurement in AntScopeZ just runs a wider/denser S11 sweep and does the time-domain IFFT client-side -- there's no separate TDR wire protocol, so shaping S11(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.

Generating scenario files

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.

Setup hints and range warnings

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), while CableFaultScenario warns 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.

Fault injection

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.

Known limitations / Troubleshooting

Stitched (multi-segment) sweeps produce a "sawtooth" curve, not one smooth curve

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.

A cable-fault TDR scan shows its impulse much closer in than the modeled distance

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.

Roadmap

  • 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 (see ScenarioCatalog::bundledScenariosDir()), which covers every way this dev tool is run today (plain build/, Qt Creator's own build/<kit>/ subdirectory, or launched by hand from the repo root) but not a real installed layout.

About

Qt6 userland NanoVNA emulator over a pty -- point AntScopeZ (or any NanoVNA client) at it without real hardware.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages