From c74ce02cfbf6b78f74d69a802bdb1e668b6deec0 Mon Sep 17 00:00:00 2001 From: Relay Date: Thu, 8 Oct 2026 12:43:02 -0500 Subject: [PATCH 1/5] Add Baofeng DM-1701 (OEM + OpenGD77) radio support Two entries, mirroring the RT3S pattern: - baofeng_dm1701: OEM firmware, dmrconf radio key dm1701 (Retevis RT84 is the same hardware, alias key rt84). Limits read from qdmr v0.15.1 lib/dm1701_limits.cc: 3000 channels, 250 zones (64 ch/zone per A/B bank), 250 scan lists of 31 channels, 250 RX group lists of 32 TGs, 10000 contacts, single DMR ID, 16-char names, 136-174 / 400-480 MHz. - baofeng_dm1701_opengd77: OpenGD77 firmware, dmrconf key openuv380. Shares the MDUV380/DM1701/RT3S/RT84 target, so limits match retevis_rt3s_opengd77 (1024 ch, 68 zones, 76 group lists, 1024 contacts, UHF capped at 470 MHz, no scan-list entity). Verified: gen_radio_table.py --check clean, 280 passed / 1 skipped, and a generated qdmr YAML passes dmrconf verify on both dm1701 and rt84. The same YAML with a 479 MHz channel is correctly rejected on the OpenGD77 key, which confirms the deliberate 480 vs 470 UHF split between the two files. Not bench-verified: no RADIO_NAMES entry for dm1701 yet, because those strings are captured from real detect runs. Device write stays fail-closed until the radio is on USB. Site rows carry no hw flag. --- radios/baofeng_dm1701/capabilities.json | 41 +++++++++++++++++++ .../baofeng_dm1701_opengd77/capabilities.json | 38 +++++++++++++++++ site/index.html | 18 ++++++++ site/radios.json | 18 ++++++++ 4 files changed, 115 insertions(+) create mode 100644 radios/baofeng_dm1701/capabilities.json create mode 100644 radios/baofeng_dm1701_opengd77/capabilities.json diff --git a/radios/baofeng_dm1701/capabilities.json b/radios/baofeng_dm1701/capabilities.json new file mode 100644 index 0000000..63a647b --- /dev/null +++ b/radios/baofeng_dm1701/capabilities.json @@ -0,0 +1,41 @@ +{ + "id": "baofeng_dm1701", + "name": "Baofeng DM-1701", + "capabilities_version": "0.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/site/index.html b/site/index.html index 0846f42..45f872f 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 + dmrconf + 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..cf4699f 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" } + }, { "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", From 20ecbeeea18e0e11e83bb27dc67a184e69190f07 Mon Sep 17 00:00:00 2001 From: Relay Date: Thu, 8 Oct 2026 15:03:16 -0500 Subject: [PATCH 2/5] dmrconf: map dm1701 to its detected name Captured from a real 'dmrconf detect' run against Eric's radio, which is the standing rule for this dict -- the value is never guessed from source. Detected name is 'Baofeng DM-1701', the qdmr display name (_name in lib/dm1701.cc), not the short --list-radios label 'DM-1701'. This unblocks the device write path, which was fail-closed on the missing key. Detect talks to the TyT DFU interface (USB 0483:df11, descriptor 'Digital Radio in USB mode'), not a serial port. It prints Found: and exits 0, then emits three benign teardown errors from lib/dfu_libusb.cc. Reproduced twice; comment added so nobody reads those as a detect failure. No RT84 entry: same hardware, but its detected string is unverified here. 280 passed, 1 skipped. --- src/codeplugger/backends/dmrconf.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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", From 9539c79df572e5b39fd6f6d29c0b71ad8000f7f3 Mon Sep 17 00:00:00 2001 From: Relay Date: Thu, 8 Oct 2026 15:18:31 -0500 Subject: [PATCH 3/5] site: badge baofeng_dm1701 headless as hardware-verified Full round trip done on Eric's radio (eam_dm1701_01) 2026-10-08: detect, binary read, profile write, read back, decode, diff. Read-back matches the written codeplug exactly -- 219 channels, 22 zones, 15 contacts, 5 group lists, channel-name lists identical -- and only 22610 of 844093 bytes differ from the factory image, so the write was targeted rather than a wholesale reinit. Confirms on DM-1701 the DM-32 behaviour already in AGENTS.md: the write progress bar lies. It sat at [ ] 0% and the wrapper never printed an exit code, yet the write had fully committed. Judge by read-back diff only. baofeng_dm1701_opengd77 deliberately NOT badged -- that firmware has never been flashed on this radio. --- site/index.html | 2 +- site/radios.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/index.html b/site/index.html index 45f872f..f83ff4a 100644 --- a/site/index.html +++ b/site/index.html @@ -135,7 +135,7 @@

Supported radios

Supported Not supported Not supported - dmrconf + dmrconfhw Retevis MateTalk P4 UHF DMR diff --git a/site/radios.json b/site/radios.json index cf4699f..bde7736 100644 --- a/site/radios.json +++ b/site/radios.json @@ -37,7 +37,7 @@ "qdmr_yaml": true, "p64_toml": false, "oem_cps": false, - "headless": { "supported": true, "backend": "dmrconf" } + "headless": { "supported": true, "backend": "dmrconf", "hw": true } }, { "id": "retevis_matetalk_p4", From f5febc3692725b3b0066a7be65990a23e04e3f3a Mon Sep 17 00:00:00 2001 From: Relay Date: Thu, 8 Oct 2026 16:09:08 -0500 Subject: [PATCH 4/5] qdmr: populate both zone channel banks on dual-bank radios qdmr models a zone as two channel banks, A and B, each bound to one VFO. The exporter hardcoded "B": [], which is right for OpenGD77 but wrong for TyT OEM firmware: that firmware renders the second display line from bank B and prints "Unprogram." when it is empty. Hardware-confirmed on a Baofeng DM-1701 2026-10-08. All 22 zones of a 219-channel codeplug wrote and read back correctly while every channel showed "Unprogram." on line 2, purely because codeplugger models a zone as one list and only filled A. Mirroring the list into both banks clears it and gives the second VFO the same channels. Driven by a new zone_banks capability rather than applied blanket, so single-bank radios (every OpenGD77 target) keep B empty and are byte-identical to before. Declared in the 0.2 schema, which is additionalProperties:false at both levels, and set to 2 only for baofeng_dm1701. Also allow tytExtension as a qdmr root extension. The OEM profile has no settings block (extensions.qdmr.settings models OpenGD77 fields this firmware lacks), so side buttons could only come out as codeplugger defaults, i.e. all Disabled. Profiles can now carry the real mapping instead of it being hardcoded or lost. --- radios/baofeng_dm1701/capabilities.json | 1 + schemas/capabilities-0.2.schema.json | 6 ++++ src/codeplugger/exporters/qdmr_yaml.py | 45 +++++++++++++++++++------ src/codeplugger/profile.py | 7 +++- tests/test_qdmr_yaml.py | 22 ++++++++++++ 5 files changed, 70 insertions(+), 11 deletions(-) diff --git a/radios/baofeng_dm1701/capabilities.json b/radios/baofeng_dm1701/capabilities.json index 63a647b..3516a67 100644 --- a/radios/baofeng_dm1701/capabilities.json +++ b/radios/baofeng_dm1701/capabilities.json @@ -2,6 +2,7 @@ "id": "baofeng_dm1701", "name": "Baofeng DM-1701", "capabilities_version": "0.2", + "zone_banks": 2, "limits": { "max_channels": 3000, "max_zones": 250, 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/src/codeplugger/exporters/qdmr_yaml.py b/src/codeplugger/exporters/qdmr_yaml.py index 60ec78f..8941dbc 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]: @@ -449,11 +454,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 +514,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 +556,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"] From 43c2525e22850f85e1d959505affc6c24896ddcb Mon Sep 17 00:00:00 2001 From: Relay Date: Thu, 8 Oct 2026 16:16:27 -0500 Subject: [PATCH 5/5] qdmr: deduplicate fleet contacts across radio instances Multiple radio instances owned by the same operator share the same dmr_id and dmr_contact_name. The fleet loop emitted one identical PrivateCall entry per instance, triplicating KC9MHE in a three-radio registry. Deduplicate by (name, number) before appending. --- src/codeplugger/exporters/qdmr_yaml.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/codeplugger/exporters/qdmr_yaml.py b/src/codeplugger/exporters/qdmr_yaml.py index 8941dbc..80c9642 100644 --- a/src/codeplugger/exporters/qdmr_yaml.py +++ b/src/codeplugger/exporters/qdmr_yaml.py @@ -308,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, } } )