- Zig 99.9%
- Nix 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The Forgejo repository has always been zig-dump1090; everything else said dump1090-zig. Now they agree: the README title, the documentation site the workflow publishes to, the flake description, REUSE's SPDX-PackageName, the version banner, and the Radicle identity. The Zig package name in build.zig.zon stays `dump1090`, and so do the module, library and executable names in build.zig -- the program is called dump1090, which is upstream's name for it and what anyone running it types. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LihwT2HJSoj6WWcUABgdSJ |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
zig-dump1090
A port of FlightAware's dump1090 — a Mode S and ADS-B decoder for software defined radios — to Zig 0.16.
The API documentation is generated from the doc comments in the source and published at https://jeff.jcollie.page/zig-dump1090/.
Where it lives
git clone ssh://git@git.jcollie.dev/jeff/zig-dump1090.git
It is also on Radicle, where a repository is findable
only by its ID, so here is that ID —
rad:z2N42qvZ6qBHsJsfUzv2JXDo2iUyH — and the command that fetches it:
rad clone rad:z2N42qvZ6qBHsJsfUzv2JXDo2iUyH
Status
Complete, in the sense that everything in scope is ported and checked: the decoder, the tracker, the demodulator, the four network outputs and two network inputs, the JSON files, the statistics, the adaptive gain control, and every command-line option upstream accepts, and it has been run against real air traffic on a live radio.
Each module is checked against the C it was ported from before the next is started, by running both over the same inputs and diffing the results field by field. Where the table below says a module agrees with upstream, that is what it means.
| Module | Upstream | State |
|---|---|---|
src/modes.zig |
dump1090.h |
Frame lengths and downlink formats |
src/crc.zig |
crc.c |
Done; syndrome tables verified identical to upstream's |
src/cpr.zig |
cpr.c |
Done; 600k random decodes agree with upstream's to 1e-9 degrees |
src/bits.zig |
mode_s.h |
Field extraction, numbered from 1 as the standards are |
src/ais.zig |
ais_charset.c |
The six-bit callsign character set |
src/icao_filter.zig |
icao_filter.c |
Done |
src/mode_ac.zig |
mode_ac.c |
Done; all 69,837 conversions agree with upstream's |
src/message.zig |
dump1090.h |
The decoded-message type and its vocabulary |
src/comm_b.zig |
comm_b.c |
Done; 400k random replies decode identically to upstream |
src/mode_s.zig |
mode_s.c |
Done; 300k frames × 3 option sets decode identically to upstream |
src/geo.zig |
track.c |
Great-circle distance and bearing |
src/track.zig |
track.c |
Done; 300k messages × 2 receiver configurations track identically to upstream |
src/dsp.zig |
dsp/ |
Done; the generated SIMD dispatch replaced by @Vector. All 65,536 table entries and 200k magnitudes identical |
src/convert.zig |
convert.c |
Done |
src/mag_buf.zig |
fifo.h |
The sample block and its overlap |
src/demod_2400.zig |
demod_2400.c |
Done; 4M samples demodulate identically, messages and counters alike |
src/fifo.zig |
fifo.c |
Done; pthreads replaced by std.Io |
src/sdr.zig |
sdr.c, sdr_ifile.c, sdr_stub.c |
Done; file, no-radio and RTL-SDR inputs |
src/output.zig |
net_io.c |
The AVR, Beast, BaseStation and Stratux wire formats |
src/net.zig |
net_io.c |
TCP output and input services, with upstream's write batching, heartbeats and Beast settings commands |
src/input.zig |
net_io.c |
Reading the Beast and AVR formats back |
src/json.zig |
net_io.c |
aircraft.json, receiver.json, stats.json and the history files |
src/stats.zig |
stats.c |
Done; the report and its windows |
src/sdr_rtlsdr.zig |
sdr_rtlsdr.c |
Done, on zig-rtlsdr rather than librtlsdr; streams from a live R820T dongle at the full 2.4 MSPS with no drops |
src/adaptive.zig |
adaptive.c |
Done; eight synthetic scenarios and a live tuner drive the gain identically to upstream, log lines included |
src/fmt.zig |
— | Printing floats the way C's printf does, ties to even |
src/display.zig |
mode_s.c |
Done; --raw, --onlyaddr and the full verbose display |
src/main.zig |
dump1090.c |
Done; every option upstream accepts is accepted, and the same lifecycle logs, signals, watchdog and exit status |
Scope
This is not a complete parity port. It targets the RTL-SDR, the file/stdin
reader used for testing, and the no-radio stub; the bladeRF, HackRF, LimeSDR
and SoapySDR back ends are out of scope, as are --interactive, view1090
and faup1090 — and with faup1090 goes upstream's FATSV output service,
which nothing else creates. The options that configure those are still
accepted, with a warning, so that an existing command line keeps running. What it does keep is the wire behaviour: the Beast, raw,
BaseStation and JSON outputs are meant to be byte-for-byte what upstream
produces, because that is what everything downstream of dump1090 parses.
Structurally it is not a transliteration. The generated SIMD dispatch under
upstream's dsp/ is replaced by Zig's @Vector, the pthreads and the bundled
anet.c by std.Io, and C's sentinel returns by error sets and tagged
unions. There is no C left at all: librtlsdr, the last of it, is replaced by
zig-rtlsdr, so the build links nothing and the binary is static.
Checking it against upstream
The port is checked by running both programs over the same recording and
diffing the output. On synthetic recordings of a few million samples, with
real frames planted at every sub-sample phase and amplitude, --raw output is
byte-for-byte identical across every combination of --fix, --fix-2bit,
--no-fix, --no-fix-df, --mlat and --modeac, in both UC8 and SC16 —
including the noisy recordings where error correction changes what gets
decoded. The three network outputs are compared the same way, by attaching a
reader to each port of both programs and diffing what arrives, and the JSON
files by diffing the snapshots the two programs write, including stats.json
and the --stats report. The network inputs are
checked by feeding the same Beast or AVR stream into both as --net-only
receivers and comparing everything that comes out the other side. The Beast
settings commands are checked by connecting to the Beast output port of both
programs, sending the same commands, and diffing what each sends back: every
combination of verbatim, local and Mode A/C produces the same bytes. The
Stratux output is checked the same way over a synthetic feed of twelve
hundred frames, chosen to reach every field it reports: all 1,188 objects the
two programs emit agree apart from the wall-clock timestamp each stamps its
own run with. The
adaptive gain control cannot be driven from a recording, since it needs a gain
knob to turn, so it is checked by linking upstream's adaptive.c against a
synthetic tuner and driving both implementations through the same few hundred
blocks of samples: eight scenarios, chosen so that between them they reach
every branch of the state machine, produce the same gain steps, the same
counters, the same noise floor to six decimal places, and the same running
commentary on standard error.
Against a live radio — a Realtek RTL2838 with an R820T tuner — the port streams at the full 2.4 MSPS with no dropped samples, and reports the same tuner gain steps as upstream. The adaptive gain control, which cannot be driven from a recording at all, finds the same gain limits (0.0 to 58.6 dB across thirty steps), makes the same decision to scan upwards, stops at the same place, and logs the same lines. Its noise floor measurement agrees exactly: at a pinned gain, over repeated captures, both programs report -35.6 dBFS at 28.0 dB of gain and -30.2 dBFS at 49.6 dB. Off the air, forty-five seconds of 1090 MHz recorded from the antenna is kept as a fixture and replayed through both programs like any other recording. It is a poor location — everything arrives weak enough to need error correction — but it contains real extended squitters from real aircraft and a few hundred Mode A/C replies, and it found two things a synthetic recording never had.
The first was ours: the RSSI line was written outside the test that guards it,
so a message with no measured signal level printed -inf dBFS where upstream
prints nothing.
The second is upstream's. demodulate2400AC clears its message once per block
of samples where the Mode S loop clears it once per message, and the Mode A/C
decoder only ever sets an altitude, never clears one — so a reply whose own
code carries no altitude is reported with whatever the previous reply in that
block had. In this recording that put a wrong altitude, belonging to another
aircraft, on nineteen of the hundred and eighty-three Mode A/C replies that
reported one. This port builds a fresh message per reply and so reports no
altitude for those nineteen, which is the one place its output deliberately
differs from upstream's.
One caution about the method: --ifile stamps each block of samples with the
wall clock as it is read, and the tracker refuses an update whose message
timestamp precedes the field's last one, so a recording read at a different
speed decodes the same messages but resolves a slightly different number of
positions. Comparisons have to be made at the same optimization level, or with
--throttle, or they will show a difference that is not there.
The verbose display — every decoded field of every message, some ten thousand lines a run — matches to the column, as does position decoding when the receiver's location is configured.
Continuous integration
A Forgejo workflow runs on every push: reuse lint, zig fmt --check,
zig build test and zig build. The formatting check excludes zig-pkg,
the in-tree cache the Zig package manager fetches dependencies into: it is
gitignored, so a fresh checkout does not have it, but a working tree does and
nobody wants to be told about the formatting of a dependency. From main, and only once the tests have
passed, it also builds the API documentation and publishes it to
https://jeff.jcollie.page/zig-dump1090/.
What the workflow cannot do is the differential testing described above, which needs upstream's C built alongside this and a good deal of scratch space; that is run by hand, and it is worth being plain that a test suite nothing runs automatically is a gap rather than a tidy-up.
Building
zig build # build zig-out/bin/dump1090
zig build test # run the tests
zig build docs # build the API documentation into zig-out/docs
zig build docs-serve # ...and read it at http://localhost:8000/
A Nix flake provides the toolchain: nix develop puts the right Zig, reuse
and the documentation publisher on the path. There is nothing else to
install — the build has no C in it and links nothing, so what comes out is a
static binary.
Talking to the dongle is zig-rtlsdr, a port of librtlsdr onto Zig that
reaches Linux's usbfs directly rather than going through libusb. One
consequence is worth knowing: it takes the dongle away from the kernel's
dvb_usb_rtl28xxu driver when it opens the device and gives it back when it
closes, so there is no module to blacklist and no dongle to unbind by hand.
Tested on a device with that driver bound: upstream's dump1090, built against
the librtlsdr nixpkgs ships, fails with usb_claim_interface error -6 and
tells you to blacklist the module; this opens it, streams, and leaves
/dev/dvb/adapter0 working again afterwards.
Being written against usbfs makes it Linux-only, so the RTL-SDR input is
compiled in only for Linux targets. -Drtlsdr=false leaves it out
explicitly; everything else works in such a build, and asking for
--device-type rtlsdr fails with NotSupported rather than silently doing
nothing.
Licence
dump1090 is GPL-2.0-or-later, so this port is too — a translation is a
derivative work, and no amount of retyping changes that. Some of the code
descends from Salvatore Sanfilippo's original BSD-licensed dump1090 by way of
Malcolm Robb's and Oliver Jowett's forks, and those files name him in their
copyright headers. Licensing is recorded in the
REUSE form, so reuse lint will confirm every
file's provenance.