diff --git a/.gitignore b/.gitignore index 1fc6b57..8f67488 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ __pycache__/ *.py[cod] .pytest_cache/ dist/ -build/ \ No newline at end of file +build/ +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 1dfdc72..0977c8a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/profiles/baofeng_uv5r_mini/reference.yml b/profiles/baofeng_uv5r_mini/reference.yml new file mode 100644 index 0000000..e51733e --- /dev/null +++ b/profiles/baofeng_uv5r_mini/reference.yml @@ -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" diff --git a/radios/baofeng_uv5r_mini/capabilities.json b/radios/baofeng_uv5r_mini/capabilities.json new file mode 100644 index 0000000..408ef63 --- /dev/null +++ b/radios/baofeng_uv5r_mini/capabilities.json @@ -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." +} diff --git a/src/codeplugger/exporters/__init__.py b/src/codeplugger/exporters/__init__.py new file mode 100644 index 0000000..1b00627 --- /dev/null +++ b/src/codeplugger/exporters/__init__.py @@ -0,0 +1 @@ +"""Exporter implementations for external codeplug formats.""" diff --git a/src/codeplugger/exporters/chirp_csv.py b/src/codeplugger/exporters/chirp_csv.py new file mode 100644 index 0000000..32c43d3 --- /dev/null +++ b/src/codeplugger/exporters/chirp_csv.py @@ -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") diff --git a/src/codeplugger/profile.py b/src/codeplugger/profile.py index be52864..3c8a15c 100644 --- a/src/codeplugger/profile.py +++ b/src/codeplugger/profile.py @@ -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)", ) @@ -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']}': " diff --git a/tests/test_chirp_csv.py b/tests/test_chirp_csv.py new file mode 100644 index 0000000..7b38775 --- /dev/null +++ b/tests/test_chirp_csv.py @@ -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)) diff --git a/tests/test_profile.py b/tests/test_profile.py index 918da7d..8bfff12 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -439,3 +439,62 @@ def test_dm32_capabilities_declare_name_limits() -> None: assert limits["max_zone_name_chars"] == 16 assert limits["max_scan_list_name_chars"] == 10 assert limits["max_contact_name_chars"] == 16 + + +def test_fm_only_radio_rejects_dmr_assignment_mode() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + profile = root / "profile.yml" + _write_profile(profile, ["asg_one"]) + _write_ssrf(root / "ssrf") + + radio = root / "radios" / "test_radio" + radio.mkdir(parents=True) + (radio / "capabilities.json").write_text( + json.dumps( + { + "id": "test_radio", + "name": "FM-only test radio", + "capabilities_version": "0.2", + "limits": { + "max_channels": 32, + "max_zones": 1, + "max_channels_per_zone": 32, + }, + "modes": ["FM"], + } + ), + encoding="utf-8", + ) + + fixture = root / "ssrf" / "systems" / "fixture.yml" + data = yaml.safe_load(fixture.read_text(encoding="utf-8")) + data["rf_chains"][0]["mode"]["type"] = "DMR" + fixture.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + with pytest.raises(ProfileValidationError, match="does not support"): + load_and_validate_profile( + profile, + [root / "ssrf"], + radio_root=root / "radios", + ) + + +def test_uv5r_mini_fixture_profile_validates_end_to_end() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_ssrf(root / "ssrf") + + fixture_profile = ( + Path(__file__).resolve().parents[1] + / "profiles" + / "baofeng_uv5r_mini" + / "reference.yml" + ) + loaded = load_and_validate_profile( + fixture_profile, + [root / "ssrf"], + ) + + assert loaded["radio"] == "baofeng_uv5r_mini" + assert loaded["zones"][0]["assignments"] == ["asg_one"]