Porting a pyModeS BDS register to FlightJar
FlightJar's Comm-B decoder matches pyModeS 3.x byte-for-byte. When adding
another register, keep the wire behaviour identical so we can cross-check
golden vectors from pyModeS's own test corpus. Every deviation (even a
"cleanup") is a source of silent drift later.
Touchpoints
A full port spans six files and one frontend + one docs update:
dotnet/src/FlightJar.Decoder/ModeS/CommB.cs — add
IsBdsXX(payload) validator + DecodeBdsXX(payload) decoder +
BdsXXData record. Extend the Candidates record and Infer() to
include the new register.
dotnet/src/FlightJar.Decoder/ModeS/DecodedMessage.cs — add
one field per value the register exposes.
dotnet/src/FlightJar.Decoder/ModeS/MessageDecoder.cs — add a
branch in InferCommB that builds a DecodedMessage for the new
register; extend Merge() to copy the new fields.
dotnet/src/FlightJar.Core/State/Aircraft.cs — add per-field
state + one BdsXXAt timestamp.
dotnet/src/FlightJar.Core/State/AircraftRegistry.cs — add a
case "X,Y": branch in ApplyCommB that writes the fields +
stamps the timestamp. Extend BuildCommBSnapshot to gate the
fields on freshness and feed the SnapshotCommB record.
dotnet/src/FlightJar.Core/State/RegistrySnapshot.cs — add
the fields to SnapshotCommB (nullable, snake-case on the wire).
app/static/detail_panel.js — add metric tiles to the
.panel-met-grid placeholder markup in buildPopupContent and
a set('.pop-met-xxx', …) call per field in renderCommBSection.
dotnet/tests/FlightJar.Decoder.Tests/ModeS/CommBTests.cs —
validator accept/reject tests + golden-vector decoder tests using
hex captures from pyModeS's tests/test_bds_commb.py.
README.md — add the new fields to the "Enhanced Mode S air
data" bullet and flag any register-specific caveats.
CLAUDE.md — update the decoder list + any behavioural
nuances.
Step 1 — Fetch the pyModeS reference
pyModeS lives at junzis/pyModeS on GitHub (default branch main).
BDS decoders live under src/pyModeS/decoder/bds/bdsXX.py, helpers
under _helpers.py. Fetch into /tmp/pymodes/ via the GitHub API
(the raw CDN sometimes 404s; the contents API is reliable):
bash
1# List available registers.
2curl -sL "https://api.github.com/repos/junzis/pyModeS/contents/src/pyModeS/decoder/bds?ref=main" \
3 | grep '"download_url"'
4
5# Fetch a specific register + the shared helpers.
6for f in bds45 _helpers _infer; do
7 curl -sL "https://raw.githubusercontent.com/junzis/pyModeS/main/src/pyModeS/decoder/bds/$f.py" \
8 > /tmp/pymodes/$f.py
9done
10
11# And the golden-vector test corpus.
12curl -sL "https://raw.githubusercontent.com/junzis/pyModeS/main/tests/test_bds_commb.py" \
13 > /tmp/pymodes/test_bds_commb.py
Step 2 — Port the validator + decoder
pyModeS operates on a 56-bit payload as a Python int with (payload >> (55 - i)) & mask
indexing (MSB-first from payload bit 0). The C# port keeps identical
bit indexing so the ported arithmetic reads one-to-one next to the Python.
Add your new methods inside public static class CommB in CommB.cs.
Use the existing helpers that are already in CommB.cs:
WrongStatus(payload, statusBit, valueStart, valueWidth) — mirrors
pyModeS _helpers.wrong_status (status-bit / value-field consistency).
Signed(value, width, sign) — sign-magnitude to signed int (NOT
two's complement; Mode S splits sign + magnitude bits).
NormaliseAngle(deg) — wrap into [0, 360).
Every range gate in IsBdsXX must match pyModeS's validator. If pyModeS
rejects > 600 kt but you port it as >= 600, you will silently accept
values pyModeS would reject.
Step 3 — Wire into inference
CommB.Infer(payload) returns a Candidates record with one bool per
heuristic register. MessageDecoder.InferCommB(msg) returns a decoded
message only when exactly one candidate validates. This single-match
discipline is intentional: multi-match payloads are ambiguous and
dropped rather than risk polluting aircraft state with fields decoded
against the wrong register. Do not relax it without a replacement
disambiguation strategy (e.g. pyModeS Phase 3 known-state scoring).
BDS 4,5 (meteorological hazard) is opt-in in pyModeS because it
false-positives on non-meteorological payloads. When porting, keep
the validator strict; if ambiguity becomes a problem, add a
Candidates.Bds45 branch behind a config flag rather than unconditionally
accepting it.
Step 4 — Extend state + snapshot
For every decoded field:
- Add a nullable property to
Aircraft (e.g. public int? WindshearLevel { get; set; }).
- Add an identically-named property to
SnapshotCommB.
- In
ApplyCommB, add a case "X,Y": that assigns from the
DecodedMessage; do not touch fields from other registers
(each register owns its own slice of state).
- In
BuildCommBSnapshot, compute a bdsXXFresh flag using
CommBMaxAge (120 s) and use it to gate every field from the
register. Include the register's BdsXXAt timestamp on the
snapshot so the frontend can age values out independently.
Naming convention on the wire: snake_case via the global serializer
config in FlightJar.Api.Configuration. MagneticHeadingDeg becomes
magnetic_heading_deg without any extra attributes.
Step 5 — Extend the frontend panel
The Enhanced Mode S panel is driven entirely by a.comm_b in the
snapshot. In detail_panel.js:
- Add placeholder tile markup inside
.panel-met-grid in
buildPopupContent — same shape as existing tiles
(<div class="metric pop-met-xxx" hidden><div class="label">…</div><div class="val"></div></div>).
- Add a
set('.pop-met-xxx', value) call in renderCommBSection
that computes the formatted string or returns null to hide the
tile.
Use the existing uconv('alt' | 'spd' | 'vrt' | 'dst', value) helper
for unit-system-aware formatting. Raw-unit values (Mach, temperature,
degrees, percent) format inline.
Step 6 — Tests
CommBTests.cs follows two patterns; use both for the new register:
- Validator acceptance + rejection: pick a golden-vector hex
from pyModeS's
test_bds_commb.py, assert IsBdsXX accepts it,
then construct synthetic payloads that should be rejected (all
zeros, out-of-range values, status-bit / value-bit inconsistency)
and assert they're rejected.
- Decode golden vector: decode the pyModeS golden hex and
assert each field matches the pyModeS oracle value to within the
same
abs= tolerance the pyModeS test uses.
Also add an end-to-end test in MessageDecoder_RoutesUnambiguousBdsXXOnDf20
to prove the InferCommB branch wires through.
Step 7 — Add an integration test
dotnet/tests/FlightJar.Core.Tests/State/AircraftRegistryTests.cs +
FakeDecoder.cs use fake hex keys (BD44 → pre-built DecodedMessage)
to exercise ApplyCommB + BuildCommBSnapshot without real wire
bytes. Add a BDxx fixture and a test proving the new field lands
in the snapshot and ages out correctly past CommBMaxAge.
Step 8 — Playwright smoke (optional)
The detail panel renders Enhanced Mode S section when comm_b is present
test in tests/e2e/layout.spec.js injects a fake snapshot with a full
comm_b block. If the new register adds a user-visible tile, extend the
fixture and assert the new label appears.
Step 9 — Verify
bash
1cd dotnet && dotnet format FlightJar.slnx --verify-no-changes
2dotnet build FlightJar.slnx
3dotnet test FlightJar.slnx
4cd ..
5node --test tests/js/
6npx playwright test
All five must pass before the port is done. Formatter drift and
Playwright regressions are the two most common surprises.
Do not
- Do not rename pyModeS's field names in the decoded record unless
you have a codebase-wide reason.
static_air_temperature in pyModeS
is StaticAirTemperatureC in CommB.cs — the C suffix is the
only concession, and it's there because our wire convention suffixes
every physical-unit field.
- Do not persist Comm-B state in
state.json.gz. The 120 s freshness
window is far shorter than the 30 s persist cadence + 10 min
PersistMaxAge, so restored Comm-B values would be stale on load.
- Do not bypass
CommB.Infer's single-match gate by eagerly decoding
every register. Multi-match payloads mean one of the decodes is
wrong; you cannot tell which without an external reference signal,
so dropping ambiguous payloads is the safe default.
- Do not add register-specific env vars for opt-in registers without
updating
README.md's configuration reference table.