diff --git a/radios/baofeng_dm1701/capabilities.json b/radios/baofeng_dm1701/capabilities.json new file mode 100644 index 0000000..3516a67 --- /dev/null +++ b/radios/baofeng_dm1701/capabilities.json @@ -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." +} diff --git a/radios/baofeng_dm1701_opengd77/capabilities.json b/radios/baofeng_dm1701_opengd77/capabilities.json new file mode 100644 index 0000000..2280fb7 --- /dev/null +++ b/radios/baofeng_dm1701_opengd77/capabilities.json @@ -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." +} diff --git a/schemas/capabilities-0.2.schema.json b/schemas/capabilities-0.2.schema.json index 6a641c6..06bf13f 100644 --- a/schemas/capabilities-0.2.schema.json +++ b/schemas/capabilities-0.2.schema.json @@ -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.", diff --git a/site/index.html b/site/index.html index 0846f42..f83ff4a 100644 --- a/site/index.html +++ b/site/index.html @@ -128,6 +128,15 @@

Supported radios

Not supported dmrconfhw + + Baofeng DM-1701 DM-1701 / RT84 + DMR · FM + Not supported + Supported + Not supported + Not supported + dmrconfhw + Retevis MateTalk P4 UHF DMR DMR · FM @@ -200,6 +209,15 @@

Supported radios

Not supported dmrconfhw + + Baofeng DM-1701 (OpenGD77) MD-UV380 family, OpenGD77 + DMR · FM + Not supported + Supported + Not supported + Not supported + dmrconf + Vero VR-N76 Dual-band, Bluetooth FM · AM RX diff --git a/site/radios.json b/site/radios.json index 4b2fc6d..bde7736 100644 --- a/site/radios.json +++ b/site/radios.json @@ -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", @@ -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", diff --git a/src/codeplugger/backends/dmrconf.py b/src/codeplugger/backends/dmrconf.py index 13f4e7b..d916def 100644 --- a/src/codeplugger/backends/dmrconf.py +++ b/src/codeplugger/backends/dmrconf.py @@ -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", diff --git a/src/codeplugger/exporters/qdmr_yaml.py b/src/codeplugger/exporters/qdmr_yaml.py index 60ec78f..80c9642 100644 --- a/src/codeplugger/exporters/qdmr_yaml.py +++ b/src/codeplugger/exporters/qdmr_yaml.py @@ -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]: @@ -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, } } ) @@ -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) @@ -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, @@ -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: diff --git a/src/codeplugger/profile.py b/src/codeplugger/profile.py index 78004ad..cfb6e3f 100644 --- a/src/codeplugger/profile.py +++ b/src/codeplugger/profile.py @@ -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 diff --git a/tests/test_qdmr_yaml.py b/tests/test_qdmr_yaml.py index 6d65cb6..76c4fdc 100644 --- a/tests/test_qdmr_yaml.py +++ b/tests/test_qdmr_yaml.py @@ -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"]