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
42 changes: 42 additions & 0 deletions radios/baofeng_dm1701/capabilities.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"id": "baofeng_dm1701",
"name": "Baofeng DM-1701",
"capabilities_version": "0.2",
"zone_banks": 2,
"limits": {
"max_channels": 3000,
"max_zones": 250,
"max_channels_per_zone": 64,
"max_scan_lists": 250,
"max_channels_per_scan_list": 31,
"max_rx_group_lists": 250,
"max_talkgroups_per_rx_group_list": 32,
"max_contacts": 10000,
"max_dmr_ids": 1,
"max_channel_name_chars": 16,
"max_zone_name_chars": 16,
"max_scan_list_name_chars": 16,
"max_contact_name_chars": 16
},
"bands": [
{
"name": "VHF",
"min_mhz": 136,
"max_mhz": 174
},
{
"name": "UHF",
"min_mhz": 400,
"max_mhz": 480
}
],
"modes": [
"FM",
"DMR"
],
"bandwidths_khz": [
12.5,
25
],
"notes": "Baofeng DM-1701 running OEM firmware, programmed through the dmrconf backend with radio key dm1701. The Retevis RT84 is the same hardware under a different badge and appears in qdmr as the alias key rt84; one entry covers both. Every limit here was read from qdmr v0.15.1 source, lib/dm1701_limits.cc (the DM1701Limits constructor), which is the exact table dmrconf verify enforces, so a profile that passes codeplugger validation is checked against the same numbers the writer will apply: channels 1-3000, zones 1-250, scan lists 0-250, group lists 1-250 with RadioLimitGroupCallRefList(1, 32) talkgroups each, and DMR contacts 1-10000. max_channels_per_zone is 64 because a zone on this family has two channel banks, A and B, each a RadioLimitRefList(0, 64) bound to one VFO; codeplugger models a zone as a single list, so 64 is the safe per-zone figure and a 128-channel zone would only fit by splitting across both banks. max_channels_per_scan_list is 31, from RadioLimitRefList(0, 31); this is a different and larger figure than the DM-32UV's 15, so do not carry DM-32UV scan-list habits over. max_dmr_ids is 1: the radioIDs list is DMRRadioID 1-1, a single radio ID, unlike the AnyTone radios that hold several. All four name fields are RadioLimitString(1, 16) Unicode. Band edges 136-174 and 400-480 MHz are declared identically for the rxFrequency and txFrequency limits of both FMChannel and DMRChannel, so the radio is dual-band for transmit across both ranges and can carry MURS and GMRS as well as the amateur bands. Note that the UHF ceiling here is 480 MHz, while the OpenGD77 limits used by the baofeng_dm1701_opengd77 entry stop at 470 MHz; the two files are deliberately different and must not be cross-copied. Bandwidths are the Narrow and Wide members of the FMChannel bandwidth enum. There is no AM mode and therefore no rx_only band. Several real capabilities of the radio have no schema field yet and are recorded here instead: a call-sign database of 122197 entries, DMR APRS positioning systems 0-16, color code 0-16, transmit power Low/Mid/High on DMR channels but only Low/High on FM channels, and both basic and AES encryption key slots. NOT BENCH-VERIFIED: as of 2026-10-08 no codeplug has been written to a physical DM-1701 through this path. These numbers are source-derived, not hardware-derived, and the site row carries no hw flag until a write and read-back diff passes on Eric's radio."
}
38 changes: 38 additions & 0 deletions radios/baofeng_dm1701_opengd77/capabilities.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"id": "baofeng_dm1701_opengd77",
"name": "Baofeng DM-1701 (OpenGD77)",
"capabilities_version": "0.2",
"limits": {
"max_channels": 1024,
"max_zones": 68,
"max_channels_per_zone": 80,
"max_scan_lists": 0,
"max_rx_group_lists": 76,
"max_talkgroups_per_rx_group_list": 32,
"max_contacts": 1024,
"max_channel_name_chars": 16,
"max_zone_name_chars": 16,
"max_contact_name_chars": 16
},
"bands": [
{
"name": "VHF",
"min_mhz": 136,
"max_mhz": 174
},
{
"name": "UHF",
"min_mhz": 400,
"max_mhz": 470
}
],
"modes": [
"FM",
"DMR"
],
"bandwidths_khz": [
12.5,
25
],
"notes": "Baofeng DM-1701 after reflashing to OpenGD77, which is a different radio as far as the toolchain is concerned: the OEM TyT codeplug format is gone and the radio enumerates as an OpenGD77 device. Program it with the dmrconf backend using radio key openuv380 (qdmr calls this OpenMDUV380); the OpenGD77 build for this hardware is the shared MDUV380/DM1701/RT3S/RT84 STM32 target, which is why this entry and retevis_rt3s_opengd77 carry identical limits. Those limits come from qdmr v0.15.1 lib/opengd77_limits.cc: 1024 channels, 68 zones of up to 80 channels, 76 RX group lists of 32 talkgroups, 1024 contacts, and 16-character names throughout. max_scan_lists is 0 on purpose and is not a placeholder: OpenGD77 has no scan-list object at all, it scans by zone, so any profile that defines scan lists must not be built for this entry. The UHF ceiling is 470 MHz rather than the 480 MHz the OEM firmware accepts, because OpenGD77Limits declares 400-470 and qdmr rejects anything above it on this key; do not raise it to match the OEM file. Capacities here are far smaller than OEM in every dimension that matters for a large codeplug, most sharply contacts at 1024 against 10000 and channels at 1024 against 3000, so flashing OpenGD77 onto a radio already carrying a full OEM codeplug is a capacity downgrade and the profile has to be rebuilt, not reused. Two dmrconf quirks apply on this key and are already handled in the backend: dmrconf verify --radio=openuv380 segfaults in 0.15.1 because cli/verify.cc constructs the radio without a device and the OpenUV380 constructor dereferences it, so the backend substitutes the opengd77 key for verification, which is equivalent because both share the one OpenGD77Limits singleton; and a device write auto-detects the model, so the wrapper never passes --radio when writing to hardware. NOT BENCH-VERIFIED: as of 2026-10-08 Eric's DM-1701 is still on OEM firmware and nothing has been written through this path, so the site row carries no hw flag. The plan is to prove the OEM path first, then flash OpenGD77 and re-test."
}
6 changes: 6 additions & 0 deletions schemas/capabilities-0.2.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
"description": "Schema version this document targets.",
"const": "0.2"
},
"zone_banks": {
"type": "integer",
"description": "Number of channel banks a zone has on this radio. TyT OEM firmware binds one bank per VFO and renders the second display line from bank B, so a dual-bank radio needs both banks populated. Defaults to 1 when absent.",
"minimum": 1,
"maximum": 2
},
"limits": {
"type": "object",
"description": "Hard structural limits enforced against a resolved profile.",
Expand Down
18 changes: 18 additions & 0 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ <h2>Supported radios</h2>
<td class="no"><span aria-hidden="true">—</span><span class="sr">Not supported</span></td>
<td class="yes"><span aria-hidden="true">✓</span> dmrconf<span class="hw" title="Verified on a physical radio">hw</span></td>
</tr>
<tr>
<th scope="row">Baofeng DM-1701 <span class="vendor">DM-1701 / RT84</span></th>
<td class="modes">DMR · FM</td>
<td class="no"><span aria-hidden="true">—</span><span class="sr">Not supported</span></td>
<td class="yes"><span aria-hidden="true">✓</span><span class="sr">Supported</span></td>
<td class="no"><span aria-hidden="true">—</span><span class="sr">Not supported</span></td>
<td class="no"><span aria-hidden="true">—</span><span class="sr">Not supported</span></td>
<td class="yes"><span aria-hidden="true">✓</span> dmrconf<span class="hw" title="Verified on a physical radio">hw</span></td>
</tr>
<tr>
<th scope="row">Retevis MateTalk P4 <span class="vendor">UHF DMR</span></th>
<td class="modes">DMR · FM</td>
Expand Down Expand Up @@ -200,6 +209,15 @@ <h2>Supported radios</h2>
<td class="no"><span aria-hidden="true">—</span><span class="sr">Not supported</span></td>
<td class="yes"><span aria-hidden="true">✓</span> dmrconf<span class="hw" title="Verified on a physical radio">hw</span></td>
</tr>
<tr>
<th scope="row">Baofeng DM-1701 (OpenGD77) <span class="vendor">MD-UV380 family, OpenGD77</span></th>
<td class="modes">DMR · FM</td>
<td class="no"><span aria-hidden="true">—</span><span class="sr">Not supported</span></td>
<td class="yes"><span aria-hidden="true">✓</span><span class="sr">Supported</span></td>
<td class="no"><span aria-hidden="true">—</span><span class="sr">Not supported</span></td>
<td class="no"><span aria-hidden="true">—</span><span class="sr">Not supported</span></td>
<td class="yes"><span aria-hidden="true">✓</span> dmrconf</td>
</tr>
<tr>
<th scope="row">Vero VR-N76 <span class="vendor">Dual-band, Bluetooth</span></th>
<td class="modes">FM · AM RX</td>
Expand Down
18 changes: 18 additions & 0 deletions site/radios.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@
"oem_cps": false,
"headless": { "supported": true, "backend": "dmrconf", "hw": true }
},
{
"id": "baofeng_dm1701",
"vendor": "DM-1701 / RT84",
"chirp_csv": false,
"qdmr_yaml": true,
"p64_toml": false,
"oem_cps": false,
"headless": { "supported": true, "backend": "dmrconf", "hw": true }
},
{
"id": "retevis_matetalk_p4",
"vendor": "UHF DMR",
Expand Down Expand Up @@ -102,6 +111,15 @@
"oem_cps": false,
"headless": { "supported": true, "backend": "dmrconf", "hw": true }
},
{
"id": "baofeng_dm1701_opengd77",
"vendor": "MD-UV380 family, OpenGD77",
"chirp_csv": false,
"qdmr_yaml": true,
"p64_toml": false,
"oem_cps": false,
"headless": { "supported": true, "backend": "dmrconf" }
},
{
"id": "vero_vrn76",
"vendor": "Dual-band, Bluetooth",
Expand Down
14 changes: 14 additions & 0 deletions src/codeplugger/backends/dmrconf.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@
# shows RadioInfo names ("OpenMDUV380") while `detect` prints the display
# name ("Open MD-UV380"). Values here were captured from real `detect` runs.
RADIO_NAMES = {
# Captured 2026-10-08 from a real `dmrconf detect` run against Eric's
# radio (eam_dm1701_01). Note this is the qdmr display name `_name` from
# lib/dm1701.cc, NOT the short `--list-radios` label "DM-1701". The
# Retevis RT84 is the same hardware and qdmr carries it as alias key
# `rt84`; its detected name is unverified here, so it gets no entry.
#
# Detect on this radio talks to the TyT DFU interface (USB 0483:df11,
# descriptor "Digital Radio in USB mode"), not a serial port. The call
# prints the Found: line and exit 0, then emits three benign teardown
# errors from lib/dfu_libusb.cc ("Cannot get status: Pipe error",
# "Cannot write to device", "Cannot leave DFU mode"). Reproduced twice;
# the radio stays enumerated and usable afterwards. Do not treat those
# lines as a detect failure -- `detect()` only parses the Found: prefix.
"dm1701": "Baofeng DM-1701",
"dm32uv": "DM-32UV",
# OpenGD77 firmware on TYT MD-UV380 hardware (Retevis RT3S included).
"openuv380": "Open MD-UV380",
Expand Down
59 changes: 47 additions & 12 deletions src/codeplugger/exporters/qdmr_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@
"all": "AllCall",
}

QDMR_ROOT_EXTENSION_KEYS = {"settings", "contacts", "positioning"}
QDMR_ROOT_EXTENSION_KEYS = {
"settings",
"contacts",
"positioning",
"tytExtension",
}


def _mapping(value: Any, *, label: str) -> Mapping[str, Any]:
Expand Down Expand Up @@ -303,18 +308,28 @@ def _contacts(
}
}
)
# Deduplicate fleet contacts by (name, number) — multiple radio instances
# belonging to the same operator share the same dmr_id and dmr_contact_name,
# which would emit one identical PrivateCall entry per instance without this.
seen_fleet: set[tuple[str, int]] = set()
for instance in (fleet_instances or {}).values():
dmr_id = instance.get("dmr_id")
if dmr_id is None:
continue
name = str(instance.get("dmr_contact_name", dmr_id))
number = int(dmr_id)
key = (name, number)
if key in seen_fleet:
continue
seen_fleet.add(key)
contacts.append(
{
"dmr": {
"id": f"cont{len(contacts) + 1}",
"name": str(instance.get("dmr_contact_name", dmr_id)),
"name": name,
"ring": False,
"type": "PrivateCall",
"number": int(dmr_id),
"number": number,
}
}
)
Expand Down Expand Up @@ -449,11 +464,17 @@ def qdmr_yaml_from_resolved(
*,
analog_bandwidth_khz: float | None = None,
fleet_instances: Mapping[str, Mapping[str, Any]] | None = None,
zone_banks: int = 1,
) -> str:
"""Return a qdmr extensible-codeplug YAML document.

DMR channels that can transmit require a radio identity. Receive-only
channels without one use the UNUSED placeholder radio ID.

zone_banks is the number of channel banks a zone has on the target
radio, taken from its capabilities document. Codeplugger models a
zone as a single list, so on a two-bank radio that list is mirrored
into both banks; see the zone construction below for why.
"""

radio_ids, id_by_key = _collect_radio_ids(codeplug)
Expand Down Expand Up @@ -503,15 +524,24 @@ def qdmr_yaml_from_resolved(
_apply_channel_extension(channel, next(iter(record.values())))
channels.append(record)

zones = [
{
"id": f"zone{index + 1}",
"name": zone.name,
"A": [channel_ids[ref] for ref in zone.channel_references],
"B": [],
}
for index, zone in enumerate(codeplug.zones)
]
# qdmr models a zone as two channel banks, A and B, each bound to one
# VFO. Single-bank radios (OpenGD77 and friends) leave B empty. On a
# dual-bank radio an empty B is not cosmetic: the TyT OEM firmware
# renders the second display line from bank B and shows "Unprogram."
# when it is empty. Hardware-confirmed on a Baofeng DM-1701 on
# 2026-10-08. Codeplugger models a zone as one list, so mirror it into
# both banks and give the second VFO the same channels.
zones = []
for index, zone in enumerate(codeplug.zones):
bank_a = [channel_ids[ref] for ref in zone.channel_references]
zones.append(
{
"id": f"zone{index + 1}",
"name": zone.name,
"A": bank_a,
"B": list(bank_a) if zone_banks >= 2 else [],
}
)

document: dict[str, Any] = {
"version": QDMR_CONFIG_VERSION,
Expand All @@ -536,6 +566,11 @@ def qdmr_yaml_from_resolved(
_merge_mapping(
document["settings"], _mapping(settings, label="qdmr settings")
)
tyt_extension = qdmr_extension.get("tytExtension")
if tyt_extension is not None:
document["tytExtension"] = deepcopy(
dict(_mapping(tyt_extension, label="qdmr tytExtension"))
)
for collection in ("contacts", "positioning"):
values = qdmr_extension.get(collection)
if values is None:
Expand Down
7 changes: 6 additions & 1 deletion src/codeplugger/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -1195,8 +1195,13 @@ def main() -> int:
fleet_instances = _load_instance_registry(args.instance_registry)[
"instances"
]
qdmr_caps = _load_capabilities(codeplug.radio_id, args.radio_root)
print(
qdmr_yaml_from_resolved(codeplug, fleet_instances=fleet_instances),
qdmr_yaml_from_resolved(
codeplug,
fleet_instances=fleet_instances,
zone_banks=int(qdmr_caps.get("zone_banks", 1)),
),
end="",
)
return 0
Expand Down
22 changes: 22 additions & 0 deletions tests/test_qdmr_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,3 +614,25 @@ def test_dmrconf_verify_accepts_analog_only_codeplug(tmp_path) -> None:
)

assert result.returncode == 0, result.stderr

def test_zone_banks_mirrors_channels_into_bank_b() -> None:
"""A dual-bank radio gets the zone list in both VFO banks.

TyT OEM firmware renders the second display line from bank B and
shows "Unprogram." when it is empty (hardware-confirmed on a
Baofeng DM-1701). Single-bank radios must keep B empty.
"""

channels = (
_channel(reference="a1", display_name="SIMPLEX 1"),
_channel(reference="a2", display_name="SIMPLEX 2"),
)
codeplug = _codeplug(channels)

single = yaml.safe_load(qdmr_yaml_from_resolved(codeplug))
assert len(single["zones"][0]["A"]) == 2
assert single["zones"][0]["B"] == []

dual = yaml.safe_load(qdmr_yaml_from_resolved(codeplug, zone_banks=2))
assert dual["zones"][0]["A"] == single["zones"][0]["A"]
assert dual["zones"][0]["B"] == dual["zones"][0]["A"]
Loading