codeplugger builds radio codeplugs from shared RF data and user-owned
configuration. It aims to be a general-purpose codeplug compiler: SSRF data
plus a profile compiles deterministically into a validated, radio-neutral
codeplug, and vendor CPS tools are replaceable export or programming
backends rather than the workflow. Current targets are the Baofeng DM-32,
Retevis MateTalk P4, and Baofeng UV-5R Mini.
Intent, guiding principles, current state, and the roadmap live in
docs/plan.md. A project overview with supported radios and
features is published at
chicago-offline.github.io/codeplugger
(source in site/).
The install pulls in ssrf-lite with its published RF data, so the shipped
examples run without any other checkout:
git clone https://github.com/Chicago-Offline/codeplugger.git
cd codeplugger
uv sync
# Validate an example and print it as CHIRP CSV
uv run codeplugger-profile examples/profiles/baofeng_uv5r_mini/example.yml \
--instance-registry examples/instances.yml \
--output-format chirp-csv
# Dry-run every radio in the example registry
uv run codeplugger-fleet \
--instance-registry examples/instances.yml \
--profiles-root examples/profilesWhen it is time to describe your own radios, copy examples/ out into its own
repository and edit from there; examples/README.md walks
through that. Profiles for real radios do not belong in this repository.
flowchart LR
public["ssrf-lite<br/>Authoritative public RF data"]
private["SSRF overlay<br/>shared or private user data"]
profile["Profile<br/>selection and ordering policy"]
registry["Instance registry<br/>fleet radios, optional"]
resolve["codeplugger<br/>Resolve and validate against radio capabilities"]
resolved["Resolved codeplug<br/>radio-neutral, deterministic"]
backend["Programming backend<br/>gated write: dmrconf, p64tool, chirp-writer, benlink"]
exporter["Exporter<br/>CHIRP CSV / qdmr YAML / P4 TOML / benlink plan"]
cps["CPS tool<br/>CHIRP / vendor CPS"]
artifacts["Artifacts<br/>HTML reference + operation log"]
radio["Radio"]
public --> resolve
private --> resolve
profile --> resolve
registry --> resolve
resolve --> resolved
resolved --> backend --> radio
resolved --> exporter --> cps --> radio
resolved --> artifacts
The diagram source is also available in docs/workflow.mmd.
ssrf-liteis the authoritative source for shareable RF facts: channel plans, repeaters, stations, talkgroups, and their schema.chioff-ssrf-testis a public reference overlay. It models the structure and merge behavior of a private SSRF repository using only public or synthetic fixture data.chioff-ssrf-sharedholds real Chicago Offline community-net RF facts (shared GMRS and MURS channels) that members merge on top ofssrf-lite.username-ssrf-privatecontains private RF records and explicit overrides ofssrf-lite, such as local names, unpublished channels, or user-specific grouping metadata.chioff-codeplugger-profiles-testcontains public reference profiles for Chicago Offline radios and provides realistic inputs for end-to-end integration tests.chioff-codeplugger-profiles-sharedcontains profiles selecting the shared community net for supported radios.username-codeplugger-profilescontains radio and output preferences: channel selection, zones, scan lists, button assignments, display settings, and per-radio variants.codepluggerresolves those inputs, validates them against radio limits, and generates files importable by a supported CPS tool.dm32-inforemains a research and reference repository. Stable findings needed for generation should become radio definitions or validation rules incodeplugger, rather than making generation depend directly on research notes.
Inputs should be merged deterministically:
- Load RF records from
ssrf-lite. - Add or override RF records from
username-ssrf-privateusing stable record identifiers, not display names. - Apply a selected codeplug profile to choose and arrange records and set radio-specific preferences.
- Validate the result against the target radio and exporter constraints.
- Generate the CPS import as a disposable build artifact.
Generated codeplugs should not become an authoritative data source. Changes should flow back into SSRF data or the selected profile and then be regenerated.
RF data describes what exists; a profile describes what a person wants programmed into a particular radio. Keeping those concerns separate makes profiles reusable across SSRF dataset updates and avoids mixing device settings into an RF schema.
The two chioff-* repositories form a public reference configuration for
integration testing:
ssrf-lite + chioff-ssrf-test + chioff-codeplugger-profiles-test -> codeplugger
The -test repositories hold only synthetic fixture data and exist to model
the overlay merge behavior. Real user repositories may follow the same layout
while remaining private. Public fixtures must not contain personal identities,
unpublished frequencies, credentials, location-sensitive records, or exports
from real radios.
The -shared repositories are the exception: they carry real Chicago Offline
community-net channels that are intended to be published and shared. They
still must not contain personal identities, credentials, or radio exports.
Small synthetic fixtures should remain in this repository for fast,
deterministic unit tests. The chioff-* repositories are intended for broader
cross-repository contract and end-to-end tests.
Profile resolution and validation, the fleet dry run, P4 TOML export with a
gated p64tool write backend, DM-32 qdmr YAML export with a gated,
hardware-verified dmrconf write backend, CHIRP CSV export for analog radios
with a gated chirp-writer write backend, Bluetooth programming of Benshi
radios through a gated benlink backend, and HTML/Markdown/operation-log
artifacts are working today. The chirp-writer path is hardware-verified on
the FT-2800M; the benlink path has not yet been accepted against a physical
radio. Prebuilt community codeplugs are published from the project site at
chicago-offline.github.io/codeplugger/codeplugs/
so members can download a file without installing anything; the DM-32 shared
profile is served today and the remaining shared profiles follow. The next
priority is moving the benlink, qdmr, and CHIRP work the project depends
on out of forks and upstream; see docs/plan.md for
the sequenced plan and what is deliberately deferred.
Repository directories:
radios/: radio capabilities, constraints, and exporter-specific mappings.examples/: runnable starter profiles and an instance registry, built only from publishedssrf-litedata.examples/usecases/holds radio-agnostic base profiles (GMRS, MURS, PMR446, marine VHF, and the combined set) that the per-radio profiles bind to hardware. Copy it to start your own profile repository; see examples/README.md.tests/fixtures/: synthetic inputs for unit tests, not usable profiles.
Profile version 0.1 selects ordered SSRF assignments by stable ID and groups
them into ordered zones for one target radio:
$schema: "https://raw.githubusercontent.com/Chicago-Offline/codeplugger/main/schemas/profile-0.1.schema.json"
version: "0.1"
id: "dm32_reference"
name: "DM-32 reference"
radio: "baofeng_dm32"
radio_instance: "dm32_reference_01"
zones:
- id: "reference"
name: "Reference"
assignments:
- "asg_fixture_reference_simplex"
- "asg_wx1"radio identifies the radio model and radio_instance identifies the specific
physical radio receiving the generated codeplug. The instance ID is arbitrary
but stable, such as dm32_green_01 or d01. Profiles that omit it retain
backward compatibility by using their profile id as the instance ID.
Profiles contain selection and ordering policy, not RF facts or per-user DMR
identities. Display-name or RF changes belong in an SSRF overlay. Version 0.1
intentionally excludes selectors, scan lists, contacts, button mappings, and
exporter settings until the explicit-ID workflow is proven end to end.
Profiles may inherit from another profile in the same directory or a relative
path. A parent that names a radio constrains the child to that radio; a
parent that names none is an abstract base, reusable on any radio that can
hold it, and the child supplies both radio and radio_instance. The child's
id and optional radio_instance are never inherited. Scalar values in the
child replace the parent. Lists of objects with an id are merged by ID, with
child fields patching matching objects and new objects appended. Use
zones_only or omit_zones for a child variant that keeps only part of the
parent's zones:
version: "0.1"
id: "dm32_lite"
name: "DM-32 lite"
radio: "baofeng_dm32"
radio_instance: "dm32_white_01"
extends: "dm32_base.yml"
zones_only: ["reference"]examples/usecases/ uses abstract bases to describe a radio by what it is for
-- a GMRS handheld, a MURS handheld, a PMR446 handheld, a marine VHF set, or
all of them at once -- so the same zone layout can be programmed onto any
radio with the capacity for it.
Inheritance cycles, unknown zones, mismatched radios, and excessively deep inheritance chains are rejected. Review the fully merged profile with:
uv run codeplugger-profile path/to/profile.yml --print-merged-profileValidate a profile against authoritative data and radio limits. With no
--ssrf-root, codeplugger reads the ssrf-lite data shipped inside the
installed dependency, so a plain install is enough for profiles that only
select published assignments:
uv run codeplugger-profile path/to/profile.ymlPass --ssrf-root explicitly, in precedence order, to use a local ssrf-lite
checkout or to layer overlays on top of it. Explicit roots replace the
installed default rather than extending it:
uv run codeplugger-profile path/to/profile.yml \
--ssrf-root ../ssrf-lite/ssrf \
--ssrf-root ../chioff-ssrf-test/ssrfA root can pin its own place in the merge with a _root.yml declaration, in
which case --ssrf-root order no longer matters for it (higher precedence
wins; duplicate values are rejected):
# ../chioff-ssrf-test/ssrf/_root.yml
ssrf_root:
id: chioff_test_overlay
precedence: 100Inspect the normalized, exporter-neutral codeplug as deterministic YAML or JSON:
uv run codeplugger-profile path/to/profile.yml \
--ssrf-root ../ssrf-lite/ssrf \
--ssrf-root ../chioff-ssrf-test/ssrf \
--output-format yamlThe normalized model contains ordered channels and zones, radio-facing RX/TX 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.
The qdmr YAML exporter accepts qdmr-native settings and DMR APRS records under
the profile's extensions.qdmr namespace. Assignment-level qdmr extensions
add fields to that generated channel. For example:
extensions:
qdmr:
settings:
introLine1: K9ABC
boot: {display: Text}
audio: {fmMicGain: 2, voxDelay: 500 ms}
dmr: {groupCallMatch: false}
gnss: {systems: [GPS]}
contacts:
- dmr:
id: aprs_contact
name: DMR APRS
ring: false
type: PrivateCall
number: 310999
positioning:
- dmr:
id: aprs1
name: DMR APRS
period: 5 min
contact: aprs_contact
zones:
- id: local
name: Local
assignments:
- id: local_dmr
extensions:
qdmr: {aprs: aprs1}Values use qdmr's extensible YAML names and syntax. IDs supplied in
contacts and positioning must be unique within the generated document.
Codeplugger rejects assignment extensions that try to replace generated RF
fields. qdmr currently maps DM-32 boot, audio/VOX, tone, timeout, GNSS, DMR,
SMS, password, and DMR APRS settings. It knows the binary locations of the
DM-32 screen colors, brightness, and backlight duration but does not expose
them through its YAML configuration model, so codeplugger cannot set them yet.
The P4 exporter requires a TOML file produced by p64tool decode as its
baseline. It updates resolved channel and zone records in place and preserves
radio-wide settings, existing contacts, RX groups, and fields codeplugger does
not yet model. It can also explicitly set analog bandwidth, clear unused
channel/zone slots, and add contacts from a fleet registry:
from codeplugger.exporters.p64_toml import p64_toml_from_resolved
output = p64_toml_from_resolved(
resolved,
baseline_toml,
fleet_instances=registry["instances"],
clear_unused_channels=True,
analog_bandwidth_khz=25.0,
digital_contact_index=1,
digital_rx_group_index=1,
)When fleet_instances is supplied, the target radio's dmr_id becomes
general.radio_dmr_id and each registry radio is appended as a private
contact. Contact and RX-group indices must refer to records already present in
the baseline; the exporter does not invent talkgroup semantics.
The external p64tool backend wrapper provides read-only info, read, and
roundtrip operations plus an explicit confirm=True write operation. The
write takes a mandatory raw backup first, refusing a path that already exists,
and always uses p64tool's known-firmware gate and read-back verification by
default.
Resolved profiles can produce a self-contained, printable HTML reference and a JSON Lines operation log under the profiles repository. The artifact format is radio-independent, so it works for DM-32, P4, and other resolved radio models:
uv run codeplugger-profile path/to/profile.yml \
--ssrf-root ../ssrf-lite/ssrf \
--ssrf-root ../chioff-ssrf-test/ssrf \
--output-format html \
--artifact-root path/to/profilesThis creates .artifacts/<radio>/<radio-instance>/reference.html and
operations.jsonl. The latter records successful profile generation and can
also be passed to a backend as an ArtifactStore to audit device reads and
writes. Generated artifacts are evidence of an operation, not an authoritative
profile or RF data source.
The reference begins with the radio model, instance identity, UTC generation
time, and channel, zone, and contact counts. It then lists channels by numbered
zone followed by the DMR contact table. When --instance-registry is supplied,
the heading uses the instance's DMR ID and color metadata and the contact table
includes every registry instance that has a DMR ID.
The physical fleet registry is the source of truth for which radio instance
gets which profile. A registry entry's profile value names a profile file
under the profile repository, and the profile's radio_instance must match the
registry key. No radio is changed by this command:
uv run codeplugger-fleet \
--instance-registry ../muehlstein-codeplugger-profiles/instances.yml \
--profiles-root ../muehlstein-codeplugger-profiles/profiles \
--ssrf-root ../ssrf-lite/ssrf \
--ssrf-root ../muehlstein-ssrf-private/ssrfThe command resolves every registered radio, validates its profile against the radio capabilities, and reports a deterministic SHA-256 of the resolved codeplug. A nonzero exit status means at least one instance is not ready; this is the planning gate before a programming adapter is invoked.
For analog radios such as the UV-5R Mini, codeplugger emits CHIRP-compatible
CSV. That CSV can either be imported in the CHIRP GUI and uploaded from there,
or handed to the gated chirp-writer backend, which programs the radio without
a GUI step.
Generate CHIRP CSV from a validated profile:
uv run codeplugger-profile path/to/profile.yml \
--ssrf-root ../ssrf-lite/ssrf \
--ssrf-root ../chioff-ssrf-test/ssrf \
--output-format chirp-csv > output.csvThen 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.
Analog radios have a single global scan and express membership negatively, per
memory, so there is no scan list to join. Set CHIRP's Skip value through an
assignment's extensions.chirp namespace:
zones:
- id: main
name: Main
assignments:
- {id: asg_wx1, display_name: "WX 1", extensions: {chirp: {skip: "S"}}}
- {id: us_ham_vhf_call, display_name: "2MCALL", extensions: {chirp: {skip: "P"}}}skip accepts only "" (scan normally), "S" (skip) and "P" (priority) —
chirp_common's three values. Individual drivers narrow that set further in
valid_skips, which the CSV exporter cannot see, so a value a radio does not
support is caught at import rather than at export. extensions.chirp is
ignored by every other exporter.
CHIRP is GPL-3 and codeplugger is Apache-2.0, so CHIRP is reached the same way
dmrconf and p64tool are: as a separate process, never an import. The
chirp-writer helper does
the CHIRP-linked work and must be installed separately, in an environment where
chirp is importable. CHIRP's own chirpc cannot substitute for it: chirpc
never applies the driver's baud rate when opening the port, and it has no
CSV-import path at all.
Which driver addresses a radio lives in that radio's capabilities.json:
"chirp": {
"driver": "Yaesu_VX-177",
"model": "Yaesu VX-177",
"clone_mode": "manual"
}model is the identity the connected radio must report, which is often not the
name on the case: an FT-277R reports as a Yaesu VX-177. clone_mode is
manual when an operator must put the radio into clone mode for every
transfer, and on_demand when the radio answers whenever the cable is
connected.
from pathlib import Path
import json
from codeplugger.artifacts import ArtifactStore
from codeplugger.backends.chirp import ChirpTarget, ChirpWriter
capabilities = json.loads(Path("radios/retevis_c64/capabilities.json").read_text())
target = ChirpTarget.from_capabilities(capabilities)
store = ArtifactStore(Path("path/to/profiles"), "retevis_c64", "jhm_c64_01")
ChirpWriter(artifact_store=store).write(
Path("output.csv"),
"/dev/cu.usbserial-110",
target=target,
backup=Path("before.img"),
read_back=Path("after.img"),
confirm=True,
)The gates, in order: confirm=True; a read_back path unless verify=False
is passed explicitly; and, inside the helper, a mandatory pre-write backup
whose reported model must match target.model. Because the backup download
happens before the upload, a wrong radio is rejected before anything is
written. Afterwards the helper re-reads the radio and diffs it per channel
against what was sent; any mismatch raises rather than returning quietly.
Unlisted memories are erased, since the generated codeplug is the whole truth
for that radio.
A radio whose capabilities carry no chirp block is refused rather than
addressed with a guessed driver.
Profiles may declare radio_instance, and codeplugger can optionally validate
that identifier against a separate instance registry document:
version: "0.1"
instances:
dm32_green_01:
radio: baofeng_dm32
label: "Green DM-32"
firmware: "DM32.01.L01.048"Use --instance-registry to enable this join check:
uv run codeplugger-profile path/to/profile.yml \
--instance-registry path/to/instances.ymlWhen enabled, codeplugger verifies:
- the profile instance ID exists in the registry
- the registry entry's
radiomatches the profile'sradio
If no registry is provided, behavior stays backward compatible with existing profile-only workflows.