Skip to content

CHIRP CSV: emit importable Duplex values; RX-only as simplex - #14

Merged
emuehlstein merged 1 commit into
mainfrom
rx-only-as-simplex
Aug 12, 2026
Merged

CHIRP CSV: emit importable Duplex values; RX-only as simplex#14
emuehlstein merged 1 commit into
mainfrom
rx-only-as-simplex

Conversation

@emuehlstein

Copy link
Copy Markdown
Contributor

Closes #13.

What was broken

really_from_csv() (chirp_common.py:515, kk7ds/chirp @ a229fae) accepts only +, -, or empty for Duplex:

if vals[3].strip() in ["+", "-", ""]:
    self.duplex = vals[3].strip()
else:
    raise errors.InvalidDataError("Duplex is not +,-, or empty")

We emitted off for receive-only channels and split for >30 MHz spreads. Both raise, so the whole row fails to import — and the export still exits 0, so it fails silently. off was reachable today via any RX-only assignment (NOAA weather being the live case).

Why RX-only became simplex, not skipped

This is the part worth arguing about, so here's the evidence rather than a preference.

Option 2 from the issue (skip + warn) drops channels. Before choosing, I decoded the reference codeplugs Eric pushed to muehlstein-codeplugger-profiles/reference/cps_exports. The receive-only blocks are stored as plain simplex with tx == rx:

  • Baofeng_BF-888_CPDCFD.img ch11–16 — CPD 453.650 / .675 / .725 / .775 / .825 / .975, every one tx == rx
  • ch2–9 — CFD 460.125–460.525, same
  • TYT_TH-9800_20240124.img — same CPD set, plus 453.1 / 453.3447 / 453.5 / 453.55

No transmit inhibit anywhere. No off, no split, no offset redirect. The receive-only intent lives in the channel name. So exporting RX-only as simplex reproduces existing practice and keeps every channel.

(Duplex=off appears in zero rows across all three reference CSVs — the value we were emitting has no precedent in the real exports.)

Changes

  • _duplex_and_offset() returns ("", 0.000000) for tx_permitted=False instead of off
  • wide splits emit the true +/- offset instead of split, so the TX frequency survives (145.000/440.000 → + 295.000000) rather than being replaced by an unparseable literal
  • DTCS codes zero-padded to three digits, matching CHIRP's own "%03i" and the reference exports (code 23 was serializing as 23, should be 023)
  • module + function docstrings record the parser constraint and the provenance, so nobody re-introduces off from CHIRP's in-memory valid_duplexes, which does allow it

Sample output

Location,Name,Frequency,Duplex,Offset,Tone,rToneFreq,cToneFreq,DtcsCode,DtcsPolarity,RxDtcsCode,CrossMode,Mode,TStep,Skip,Power,Comment
1,FAM ALL,462.575000,,0.000000,DTCS,88.5,88.5,023,NN,023,Tone->Tone,FM,5.00,,High,All
3,FAM REPEATER,462.550000,+,5.000000,DTCS,88.5,88.5,023,NN,023,Tone->Tone,FM,5.00,,High,Eric's Repeater
5,WX1 CHICAGO,162.550000,,0.000000,,88.5,88.5,023,NN,023,Tone->Tone,FM,5.00,,High,NOAA weather - receive only
6,CPD CITYWIDE 1,453.650000,,0.000000,,88.5,88.5,023,NN,023,Tone->Tone,FM,5.00,,High,Chicago PD dispatch - receive only

Tests

uv run pytest -q24 passed (was 21).

  • receive-only exports as simplex, not off
  • Duplex is always in {+, -, ""} across RX-only, wide-split, and repeater channels — the parser contract, asserted directly
  • wide split keeps the real offset
  • DTCS zero-padding regression

⚠️ Reviewer caveat — read before merging

A receive-only channel exported this way is transmit-capable in CHIRP. Nothing in a CHIRP CSV can express a TX inhibit: Power (col 16) and Comment (col 17) are written by to_csv() but never read back by really_from_csv(), which stops at vals[14]. BCL isn't a CSV field at all.

So this is not a safety regression versus the reference codeplugs — they don't block TX either — but it is a real property: nothing stops a TX on 453.775 except the operator reading the name. If we want enforced RX-only, it has to happen outside the CSV path (radio-side via the driver, or by refusing to include those channels).

One loose end I did not resolve: flag byte [12] in the BF-888 structs varies (0x07 on all 460.x; 0x03/0x0b on 453.x, bit3 set only on 453.650 and 453.775). I read it as bandwidth or busy-lock rather than TX inhibit since it doesn't track the band split — but that's an inference from 16 channels, not a verified decode. If it is a TX inhibit, the precedent argument above gets stronger, not weaker.

CHIRP's CSV parser (chirp_common.really_from_csv, kk7ds/chirp @ a229fae)
accepts only "+", "-" or empty for Duplex. The exporter emitted "off" for
receive-only channels and "split" for >30 MHz spreads; both raise
InvalidDataError, so any file containing such a row failed to import.
"off" was reachable today via any RX-only assignment (e.g. NOAA weather),
and the export still exited 0, so the breakage was silent.

Receive-only channels now export as plain simplex. This matches how they
are actually stored in our reference codeplugs: the CPD/CFD receive-only
blocks in muehlstein-codeplugger-profiles (BF-888_CPDCFD.img ch11-16 and
TYT_TH-9800_20240124.img) hold tx == rx with no transmit inhibit, carrying
the intent in the channel name. No channels are dropped.

Wide splits now emit the true +/- offset instead of "split", so the TX
frequency survives rather than being replaced by an unparseable literal.

Also zero-pad DTCS codes to three digits to match CHIRP's own "%03i"
output and the existing reference exports; code 23 previously serialized
as "23".

Caveat worth reviewing: a receive-only channel exported this way is
transmit-capable in CHIRP. Nothing in a CHIRP CSV can express a TX
inhibit (Power and BCL are not read back by the parser), so callers that
need transmit actually blocked must enforce it outside the CSV.
@emuehlstein

Copy link
Copy Markdown
Contributor Author

Loose end closed: BF-888 byte 12 fully decoded

The PR body flags flag byte [12] as an unresolved inference ("bandwidth or busy-lock ... not a verified decode"). It is now verified, using the CSV export of BF-888_CPDCFD that Eric pushed to muehlstein-codeplugger-profiles/reference/cps_exports.

Two independent bit assignments, 16/16 agreement with the CPS's own CSV:

bit meaning evidence
bit2 narrow FM set on all 8 rows the CSV reports as NFM (460.125–460.525), clear on all 8 FM rows
bit3 high power set on exactly the 3 rows the CSV reports as 5.0W (453.650, 453.775, 453.100), clear on all 13 1.0W rows

So 0x07 = NFM + low, 0x03 = FM + low, 0x0b = FM + high. bit0/bit1 are set on every populated channel (occupancy/enable).

Byte 12 has nothing to do with transmit. No bit in it gates TX on any of these 16 channels, including the CPD dispatch frequencies. This is corroborated independently by BF-888_BEINRPTS.img, which is uniformly 0x0b across 11 populated channels that are all TX-capable, five of them repeaters — so 0x0b cannot be a transmit inhibit.

This strengthens rather than weakens the PR's reasoning: the receive-only intent genuinely is not encoded anywhere in the codeplug, so exporting RX-only as plain simplex loses nothing that was ever there. The reviewer caveat stands as written, now verified across 32 channels in two configs rather than inferred from 16.

One correction to the PR body

The PR describes the CPD block as BF-888_CPDCFD.img "ch11–16". The memory array starts at byte 16, not 0 — the first 16 bytes are ff×16 (header/reserved), which I had been decoding as an empty channel 1. Corrected map:

  • ch1–8 — CFD 460.125 / .175 / .275 / .325 / .350 / .250 / .300 / .525
  • ch9 — 462.975
  • ch10–16 — CPD 453.650 / .725 / .775 / .825 / .975 / .675 / .100

CPDCFD is a full 16 channels with no gaps, and 453.100 was previously falling past the end of my decode. Verified by cross-checking BF-888_BEINRPTS.img against its CSV twin: all 12 rows match at base=16, and every TX frequency reconstructs exactly from Duplex+Offset.

The substantive finding is unaffected — every channel in CPDCFD is still tx == rx, zero splits, re-confirmed at the corrected offset. Only the slot numbering and the channel count were wrong, not the simplex conclusion the PR rests on.

Incidental: sparse slots are native to the format. BEINRPTS.csv jumps Location 7→12 and the image has real ffff gaps in exactly those positions, so non-contiguous numbering is not an export artifact.

@emuehlstein
emuehlstein merged commit b17f46a into main Aug 12, 2026
2 checks passed
@emuehlstein

Copy link
Copy Markdown
Contributor Author

The BF-888 image-format findings from this thread now have a permanent home: https://github.com/Chicago-Offline/bf888-info

Moved so they don't stay buried in a merged-PR comment. FORMAT.md covers the base=16 offset, little-endian BCD frequencies, the byte-12 flag decode (bit2 narrow FM, bit3 high power, 16/16 CSV agreement), and the BEINRPTS cross-check ruling out anything TX-related in that byte.

Also carries the tx == rx / zero-duplex=off precedent that settled the RX-only design here, and the CHIRP write-21-read-15 asymmetry — with the correction that this does not make Power and Comment decorative, since the Baofeng CPS reads further than CHIRP's own parser and real exports populate both.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CHIRP CSV export emits Duplex values (split, off) that CHIRP's CSV parser rejects

1 participant