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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ __pycache__/
*.py[cod]
.pytest_cache/
dist/
build/
build/
.DS_Store
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,26 @@ frequencies, mode, service, analog tones, overlay-resolved notes, and explicit
TX permission. It intentionally excludes scan lists, contacts, DMR identities,
button settings, and NeonPlug fields; exporters translate this stable model
without participating in input resolution.

## CHIRP workflow for analog radios

For analog radios such as the UV-5R Mini, codeplugger can emit CHIRP-compatible
CSV and CHIRP remains the upload interface to the radio.

Generate CHIRP CSV from a validated profile:

```bash
uv run codeplugger-profile path/to/profile.yml \
--ssrf-root ../ssrf-lite/ssrf \
--ssrf-root ../chioff-ssrf-test/ssrf \
--output-format chirp-csv > output.csv
```

Then import `output.csv` in CHIRP and upload from CHIRP to the radio.

Notes:

- CHIRP CSV export currently supports FM channels only.
- Any non-FM channel selected by the profile fails export with an explicit
error.
- Channel order in the CSV matches resolved profile order.
11 changes: 11 additions & 0 deletions profiles/baofeng_uv5r_mini/reference.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
$schema: "https://raw.githubusercontent.com/Chicago-Offline/codeplugger/main/schemas/profile-0.1.schema.json"
version: "0.1"
id: "muehlstein_uv5r_mini"
name: "UV-5R Mini reference fixture"
radio: "baofeng_uv5r_mini"
radio_instance: "5rm_01"
zones:
- id: "reference"
name: "Reference"
assignments:
- "asg_one"
26 changes: 26 additions & 0 deletions radios/baofeng_uv5r_mini/capabilities.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"id": "baofeng_uv5r_mini",
"name": "Baofeng UV-5R Mini",
"capabilities_version": "0.2",
"limits": {
"max_channels": 128,
"max_zones": 1,
"max_channels_per_zone": 128,
"max_channel_name_chars": 7
},
"bands": [
{
"name": "VHF",
"min_mhz": 136.0,
"max_mhz": 174.0
},
{
"name": "UHF",
"min_mhz": 400.0,
"max_mhz": 520.0
}
],
"modes": ["FM"],
"bandwidths_khz": [12.5, 25.0],
"notes": "UV-5R Mini is modeled as FM-only for codeplugger mode validation. Limits and RF/bandwidth ranges are based on published UV-5R Mini spec sheets and CHIRP profile conventions: 128 channel memories, dual-band 136-174 and 400-520 MHz coverage, and narrow/wide FM (12.5/25 kHz). max_zones is set to 1 as a profile-0.1 compatibility shim because profile-0.1 always requires at least one zone; the physical radio does not expose user-defined zones in the same sense as DMR handhelds. max_channel_name_chars is 7, matching common UV-5R display behavior for channel names."
}
1 change: 1 addition & 0 deletions src/codeplugger/exporters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Exporter implementations for external codeplug formats."""
140 changes: 140 additions & 0 deletions src/codeplugger/exporters/chirp_csv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Export resolved analog channels as CHIRP-compatible CSV."""

from __future__ import annotations

from csv import DictWriter
from io import StringIO
from pathlib import Path

from ..resolved import ResolvedChannel, ResolvedCodeplug


CHIRP_HEADERS = [
"Location",
"Name",
"Frequency",
"Duplex",
"Offset",
"Tone",
"rToneFreq",
"cToneFreq",
"DtcsCode",
"DtcsPolarity",
"RxDtcsCode",
"CrossMode",
"Mode",
"TStep",
"Skip",
"Power",
"Comment",
]


def _format_frequency(value_mhz: float) -> str:
return f"{value_mhz:.6f}"


def _tone_fields(channel: ResolvedChannel) -> dict[str, str]:
tones = channel.tones
has_ctcss = tones.ctcss_tx_hz is not None or tones.ctcss_rx_hz is not None
has_dcs = tones.dcs_tx_code is not None or tones.dcs_rx_code is not None

fields = {
"Tone": "",
"rToneFreq": "88.5",
"cToneFreq": "88.5",
"DtcsCode": "023",
"DtcsPolarity": "NN",
"RxDtcsCode": "023",
"CrossMode": "Tone->Tone",
}

if not has_ctcss and not has_dcs:
return fields

if has_ctcss and has_dcs:
raise ValueError(
f"channel '{channel.display_name}' mixes CTCSS and DCS tones, "
"which this CHIRP export path does not support"
)

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
return fields

tx_tone = tones.ctcss_tx_hz
rx_tone = tones.ctcss_rx_hz
if tx_tone is not None:
fields["rToneFreq"] = f"{tx_tone:.1f}"
if rx_tone is not None:
fields["cToneFreq"] = f"{rx_tone:.1f}"

if tx_tone is not None and rx_tone is None:
fields["Tone"] = "Tone"
return fields

fields["Tone"] = "TSQL"
if tx_tone is None and rx_tone is not None:
fields["rToneFreq"] = fields["cToneFreq"]
return fields


def _duplex_and_offset(channel: ResolvedChannel) -> tuple[str, str]:
if not channel.tx_permitted or channel.tx_frequency_mhz is None:
return "off", _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)


def chirp_csv_from_resolved(codeplug: ResolvedCodeplug) -> str:
"""Return CHIRP CSV text for resolved analog channels."""

output = StringIO()
writer = DictWriter(output, fieldnames=CHIRP_HEADERS, lineterminator="\n")
writer.writeheader()

for idx, channel in enumerate(codeplug.channels, start=1):
mode = (channel.mode or "FM").upper()
if mode != "FM":
raise ValueError(
f"channel '{channel.display_name}' mode '{mode}' is unsupported "
"for CHIRP CSV export"
)

duplex, offset = _duplex_and_offset(channel)
row = {
"Location": str(idx),
"Name": channel.display_name,
"Frequency": _format_frequency(channel.rx_frequency_mhz),
"Duplex": duplex,
"Offset": offset,
"Mode": "FM",
"TStep": "5.00",
"Skip": "",
"Power": "High",
"Comment": channel.notes or "",
}
row.update(_tone_fields(channel))
writer.writerow(row)

return output.getvalue()


def write_chirp_csv(path: Path, codeplug: ResolvedCodeplug) -> None:
"""Write CHIRP CSV to ``path``."""

path.write_text(chirp_csv_from_resolved(codeplug), encoding="utf-8")
7 changes: 6 additions & 1 deletion src/codeplugger/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ def main() -> int:
parser.add_argument("--radio-root", type=Path, default=DEFAULT_RADIO_ROOT)
parser.add_argument(
"--output-format",
choices=("summary", "json", "yaml"),
choices=("summary", "json", "yaml", "chirp-csv"),
default="summary",
help="inspection output format (default: summary)",
)
Expand Down Expand Up @@ -350,6 +350,11 @@ def main() -> int:
if args.output_format == "yaml":
print(codeplug.to_yaml(), end="")
return 0
if args.output_format == "chirp-csv":
from .exporters.chirp_csv import chirp_csv_from_resolved

print(chirp_csv_from_resolved(codeplug), end="")
return 0
assignment_count = sum(len(zone["assignments"]) for zone in profile["zones"])
print(
f"Validated profile '{profile['id']}': "
Expand Down
108 changes: 108 additions & 0 deletions tests/test_chirp_csv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
from __future__ import annotations

import csv
import io

import pytest

from codeplugger.exporters.chirp_csv import chirp_csv_from_resolved
from codeplugger.resolved import (
ResolvedChannel,
ResolvedCodeplug,
ResolvedTones,
ResolvedZone,
)


def _codeplug(channels: list[ResolvedChannel]) -> ResolvedCodeplug:
refs = tuple(channel.reference for channel in channels)
return ResolvedCodeplug(
radio_id="baofeng_uv5r_mini",
radio_instance_id="5rm_01",
channels=tuple(channels),
zones=(ResolvedZone(id="z1", name="Reference", channel_references=refs),),
)


def test_chirp_csv_preserves_channel_order_and_fields() -> None:
channels = [
ResolvedChannel(
reference="asg_1",
assignment_id="asg_1",
display_name="SIMPLEX",
rx_frequency_mhz=146.520,
tx_frequency_mhz=146.520,
mode="FM",
service="amateur",
tones=ResolvedTones(),
tx_permitted=True,
notes="local simplex",
),
ResolvedChannel(
reference="asg_2",
assignment_id="asg_2",
display_name="RPT-",
rx_frequency_mhz=147.390,
tx_frequency_mhz=146.790,
mode="FM",
service="amateur",
tones=ResolvedTones(ctcss_tx_hz=100.0),
tx_permitted=True,
notes=None,
),
]
text = chirp_csv_from_resolved(_codeplug(channels))
rows = list(csv.DictReader(io.StringIO(text)))

assert [row["Location"] for row in rows] == ["1", "2"]
assert [row["Name"] for row in rows] == ["SIMPLEX", "RPT-"]
assert rows[0]["Frequency"] == "146.520000"
assert rows[0]["Duplex"] == ""
assert rows[0]["Offset"] == "0.000000"
assert rows[0]["Comment"] == "local simplex"
assert rows[1]["Duplex"] == "-"
assert rows[1]["Offset"] == "0.600000"
assert rows[1]["Tone"] == "Tone"
assert rows[1]["rToneFreq"] == "100.0"


def test_chirp_csv_sets_tx_off_for_receive_only_channels() -> None:
channels = [
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=None,
)
]
text = chirp_csv_from_resolved(_codeplug(channels))
row = list(csv.DictReader(io.StringIO(text)))[0]

assert row["Duplex"] == "off"
assert row["Offset"] == "0.000000"


def test_chirp_csv_rejects_non_fm_modes() -> None:
channels = [
ResolvedChannel(
reference="dmr_1",
assignment_id="dmr_1",
display_name="DMR TG",
rx_frequency_mhz=443.100,
tx_frequency_mhz=448.100,
mode="DMR",
service="dmr",
tones=ResolvedTones(),
tx_permitted=True,
notes=None,
)
]

with pytest.raises(ValueError, match="unsupported"):
chirp_csv_from_resolved(_codeplug(channels))
Loading
Loading