diff --git a/src/codeplugger/exporters/chirp_csv.py b/src/codeplugger/exporters/chirp_csv.py index 32c43d3..5e458a5 100644 --- a/src/codeplugger/exporters/chirp_csv.py +++ b/src/codeplugger/exporters/chirp_csv.py @@ -1,4 +1,19 @@ -"""Export resolved analog channels as CHIRP-compatible CSV.""" +"""Export resolved analog channels as CHIRP-compatible CSV. + +Two constraints shape this exporter: + +``Duplex`` is limited to ``+``, ``-`` or empty. CHIRP's in-memory model also +allows ``split`` and ``off``, but its CSV *parser* does not +(``chirp_common.really_from_csv``, verified against ``kk7ds/chirp`` @ +``a229fae``), so emitting either produces a file CHIRP cannot import. + +Receive-only channels are exported as ordinary simplex channels. That mirrors +existing practice in our own reference codeplugs: in +``muehlstein-codeplugger-profiles`` the CPD/CFD receive-only blocks +(``BF-888_CPDCFD.img`` ch11-16, ``TYT_TH-9800``) are stored with ``tx == rx`` +and no transmit inhibit, with the intent carried in the channel name. Callers +that need transmit actually blocked must enforce it outside the CSV. +""" from __future__ import annotations @@ -60,10 +75,13 @@ def _tone_fields(channel: ResolvedChannel) -> dict[str, str]: if has_dcs: fields["Tone"] = "DTCS" - tx_code = str(tones.dcs_tx_code or tones.dcs_rx_code) - rx_code = str(tones.dcs_rx_code or tones.dcs_tx_code) - fields["DtcsCode"] = tx_code - fields["RxDtcsCode"] = rx_code + # CHIRP writes DTCS codes zero-padded to three digits ("%03i"), and + # existing reference exports use that form. Match it so generated CSVs + # are byte-comparable with CPS/CHIRP output. + tx_code = tones.dcs_tx_code if tones.dcs_tx_code is not None else tones.dcs_rx_code + rx_code = tones.dcs_rx_code if tones.dcs_rx_code is not None else tones.dcs_tx_code + fields["DtcsCode"] = f"{int(tx_code):03d}" + fields["RxDtcsCode"] = f"{int(rx_code):03d}" return fields tx_tone = tones.ctcss_tx_hz @@ -84,18 +102,27 @@ def _tone_fields(channel: ResolvedChannel) -> dict[str, str]: def _duplex_and_offset(channel: ResolvedChannel) -> tuple[str, str]: + """Return CHIRP ``(Duplex, Offset)`` for one channel. + + CHIRP's CSV reader (``chirp_common.really_from_csv``) accepts only ``+``, + ``-`` or an empty ``Duplex``; ``split`` and ``off`` raise + ``InvalidDataError`` and make the whole row unimportable. Every value + returned here is therefore one of those three. + + Receive-only channels are emitted as plain simplex, matching how they are + actually stored in our reference codeplugs (see module docstring). The + receive-only intent is carried by the channel name/comment, not by the + frequency fields. + """ + if not channel.tx_permitted or channel.tx_frequency_mhz is None: - return "off", _format_frequency(0.0) + return "", _format_frequency(0.0) diff = channel.tx_frequency_mhz - channel.rx_frequency_mhz abs_diff = abs(diff) if abs_diff < 1e-6: return "", _format_frequency(0.0) - # CHIRP uses "split" when TX cannot be represented as +/- offset. - if abs_diff > 30.0: - return "split", _format_frequency(channel.tx_frequency_mhz) - duplex = "+" if diff > 0 else "-" return duplex, _format_frequency(abs_diff) diff --git a/tests/test_chirp_csv.py b/tests/test_chirp_csv.py index 88e46c3..42381eb 100644 --- a/tests/test_chirp_csv.py +++ b/tests/test_chirp_csv.py @@ -67,26 +67,101 @@ def test_chirp_csv_preserves_channel_order_and_fields() -> None: assert rows[1]["rToneFreq"] == "100.0" -def test_chirp_csv_sets_tx_off_for_receive_only_channels() -> None: +def _receive_only_channel() -> ResolvedChannel: + return ResolvedChannel( + reference="wx1", + assignment_id="wx1", + display_name="WX1", + rx_frequency_mhz=162.550, + tx_frequency_mhz=None, + mode="FM", + service="weather", + tones=ResolvedTones(), + tx_permitted=False, + notes="receive only", + ) + + +def test_chirp_csv_emits_receive_only_channels_as_simplex() -> None: + """Receive-only channels export as plain simplex, not ``Duplex=off``. + + ``off`` is rejected by CHIRP's CSV parser, and our reference codeplugs + store receive-only blocks as ``tx == rx`` anyway. + """ + + text = chirp_csv_from_resolved(_codeplug([_receive_only_channel()])) + row = list(csv.DictReader(io.StringIO(text)))[0] + + assert row["Duplex"] == "" + assert row["Offset"] == "0.000000" + assert row["Frequency"] == "162.550000" + # The receive-only intent has to survive somewhere the parser keeps. + assert row["Name"] == "WX1" + + +def test_chirp_csv_never_emits_duplex_values_chirp_cannot_parse() -> None: + """Guard the parser contract: Duplex is always ``+``, ``-`` or empty. + + Mirrors the accepted set in ``chirp_common.really_from_csv``. + """ + + channels = [ + _receive_only_channel(), + # A >30 MHz spread previously became "split", which CHIRP rejects. + ResolvedChannel( + reference="wide", + assignment_id="wide", + display_name="WIDE SPLIT", + rx_frequency_mhz=145.000, + tx_frequency_mhz=440.000, + mode="FM", + service="amateur", + tones=ResolvedTones(), + tx_permitted=True, + notes=None, + ), + ResolvedChannel( + reference="rpt", + assignment_id="rpt", + display_name="RPT+", + rx_frequency_mhz=462.550, + tx_frequency_mhz=467.550, + mode="FM", + service="gmrs", + tones=ResolvedTones(), + tx_permitted=True, + notes=None, + ), + ] + + rows = list(csv.DictReader(io.StringIO(chirp_csv_from_resolved(_codeplug(channels))))) + + assert [row["Duplex"] for row in rows] == ["", "+", "+"] + for row in rows: + assert row["Duplex"] in {"+", "-", ""} + + +def test_chirp_csv_wide_split_offset_is_the_true_difference() -> None: + """A wide split is emitted as a real offset, so TX is not silently lost.""" + channels = [ ResolvedChannel( - reference="wx1", - assignment_id="wx1", - display_name="WX1", - rx_frequency_mhz=162.550, - tx_frequency_mhz=None, + reference="wide", + assignment_id="wide", + display_name="WIDE SPLIT", + rx_frequency_mhz=145.000, + tx_frequency_mhz=440.000, mode="FM", - service="weather", + service="amateur", tones=ResolvedTones(), - tx_permitted=False, + tx_permitted=True, notes=None, ) ] - text = chirp_csv_from_resolved(_codeplug(channels)) - row = list(csv.DictReader(io.StringIO(text)))[0] + row = list(csv.DictReader(io.StringIO(chirp_csv_from_resolved(_codeplug(channels)))))[0] - assert row["Duplex"] == "off" - assert row["Offset"] == "0.000000" + assert row["Duplex"] == "+" + assert row["Offset"] == "295.000000" def test_chirp_csv_rejects_non_fm_modes() -> None: @@ -107,3 +182,31 @@ def test_chirp_csv_rejects_non_fm_modes() -> None: with pytest.raises(ValueError, match="unsupported"): chirp_csv_from_resolved(_codeplug(channels)) + + +def test_chirp_csv_zero_pads_dtcs_codes() -> None: + """DTCS codes are three-digit, matching CHIRP's own "%03i" output. + + Code 23 must serialize as "023"; bare "23" is inconsistent with every + reference export even though CHIRP's parser would accept it. + """ + + channels = [ + ResolvedChannel( + reference="dcs", + assignment_id="dcs", + display_name="FAM ALL", + rx_frequency_mhz=462.575, + tx_frequency_mhz=462.575, + mode="FM", + service="gmrs", + tones=ResolvedTones(dcs_tx_code=23, dcs_rx_code=23), + tx_permitted=True, + notes="All", + ) + ] + row = list(csv.DictReader(io.StringIO(chirp_csv_from_resolved(_codeplug(channels)))))[0] + + assert row["Tone"] == "DTCS" + assert row["DtcsCode"] == "023" + assert row["RxDtcsCode"] == "023"