Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions src/codeplugger/exporters/chirp_csv.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
127 changes: 115 additions & 12 deletions tests/test_chirp_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
Loading