From 4cfca1bd4c5174f108b0e352cd608fca769dcb4c Mon Sep 17 00:00:00 2001 From: Relay Date: Thu, 6 Aug 2026 22:04:58 -0500 Subject: [PATCH] Extend radio capabilities model with bands, modes, and bandwidths The capabilities model was three integers (max_channels, max_zones, max_channels_per_zone). That answers 'will this profile fit' but not 'will these channels work on this radio', so a profile could select out-of-band or unsupported-mode channels and validate clean. Concretely: the MateTalk P4 is UHF-only (400-470 MHz), but nothing stopped a profile putting MURS at 151/154 MHz on it. That had to be caught by hand when writing the shared ChiOff profiles; the validator would have emitted a codeplug with unusable channels. Adds three optional capability fields: - bands[]: tunable ranges in MHz, with optional rx_only for receive-only spans. Checked against each selected channel's RX and TX frequency. - modes[]: FM / DMR support. Checked against each chain's mode type. - bandwidths_khz[]: supported analog bandwidths, checked when a chain declares one. Every check is skipped when its field is absent, so an incomplete capabilities file degrades to current behavior rather than emitting false failures. Existing files stay valid. Also adds schemas/capabilities-0.2.schema.json and validates capabilities.json against it at load. Previously a typo in a limits key surfaced as a raw KeyError; it now reports the offending property. Populates both radios. DM-32 band edges and scan-list count are cross-checked against qdmr's DM32UVLimits (lib/dm32uv_limits.cc), which declares 136-174 / 400-480 MHz and a narrow/wide bandwidth enum. Prior art: qdmr models per-radio limits as a RadioLimits tree with RadioLimitFrequencies holding explicit MHz ranges per channel type, and NeonPlug's RadioCapabilities carries bandLimits plus analogOnly and per-object maxima. Both treat band coverage as a first-class radio fact. This change takes the same shape at the scope Codeplugger needs, leaving scan lists, contacts, and DMR identities out of the resolved model as the README already specifies. Verified: both ChiOff shared profiles still validate unchanged; MURS on the P4 now fails with an explicit band error; a DMR channel on an analog-only radio fails with a mode error; an unknown capabilities key fails with a schema error instead of KeyError. --- radios/baofeng_dm32/capabilities.json | 23 +++- radios/retevis_matetalk_p4/capabilities.json | 13 +- schemas/capabilities-0.2.schema.json | 119 +++++++++++++++++++ src/codeplugger/profile.py | 109 +++++++++++++++++ 4 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 schemas/capabilities-0.2.schema.json diff --git a/radios/baofeng_dm32/capabilities.json b/radios/baofeng_dm32/capabilities.json index b311d9c..e205064 100644 --- a/radios/baofeng_dm32/capabilities.json +++ b/radios/baofeng_dm32/capabilities.json @@ -1,9 +1,26 @@ { "id": "baofeng_dm32", "name": "Baofeng DM-32", + "capabilities_version": "0.2", "limits": { "max_channels": 4000, "max_zones": 250, - "max_channels_per_zone": 250 - } -} \ No newline at end of file + "max_channels_per_zone": 250, + "max_scan_lists": 32 + }, + "bands": [ + { + "name": "VHF", + "min_mhz": 136.0, + "max_mhz": 174.0 + }, + { + "name": "UHF", + "min_mhz": 400.0, + "max_mhz": 480.0 + } + ], + "modes": ["FM", "DMR"], + "bandwidths_khz": [12.5, 25.0], + "notes": "Dual-band VHF/UHF, so it can carry MURS (151/154 MHz) as well as GMRS. Band edges and scan-list count cross-checked against qdmr's DM32UVLimits (lib/dm32uv_limits.cc), which declares 136-174 and 400-480 MHz and a narrow/wide bandwidth enum." +} diff --git a/radios/retevis_matetalk_p4/capabilities.json b/radios/retevis_matetalk_p4/capabilities.json index f63ad64..24aef5b 100644 --- a/radios/retevis_matetalk_p4/capabilities.json +++ b/radios/retevis_matetalk_p4/capabilities.json @@ -1,9 +1,20 @@ { "id": "retevis_matetalk_p4", "name": "Retevis MateTalk P4", + "capabilities_version": "0.2", "limits": { "max_channels": 256, "max_zones": 16, "max_channels_per_zone": 16 - } + }, + "bands": [ + { + "name": "UHF", + "min_mhz": 400.0, + "max_mhz": 470.0 + } + ], + "modes": ["FM", "DMR"], + "bandwidths_khz": [12.5, 25.0], + "notes": "UHF-only: the radio has no VHF band, so MURS (151/154 MHz) and 2m cannot be programmed. Bandwidth is selectable wide/narrow; the Features page of the user manual states 'Wideband/Narrowband Selectable'." } diff --git a/schemas/capabilities-0.2.schema.json b/schemas/capabilities-0.2.schema.json new file mode 100644 index 0000000..b165120 --- /dev/null +++ b/schemas/capabilities-0.2.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Chicago-Offline/codeplugger/main/schemas/capabilities-0.2.schema.json", + "title": "Codeplugger radio capabilities", + "description": "Describes what a target radio can physically accept. Scoped to facts a generator needs in order to decide whether a profile will fit and work on the radio, not to model the radio's full feature set.", + "type": "object", + "required": ["id", "name", "limits"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Stable radio identifier; must match the containing directory name.", + "pattern": "^[a-z0-9_]+$" + }, + "name": { + "type": "string", + "description": "Human-readable radio name.", + "minLength": 1 + }, + "capabilities_version": { + "type": "string", + "description": "Schema version this document targets.", + "const": "0.2" + }, + "limits": { + "type": "object", + "description": "Hard structural limits enforced against a resolved profile.", + "required": ["max_channels", "max_zones", "max_channels_per_zone"], + "additionalProperties": false, + "properties": { + "max_channels": { + "type": "integer", + "minimum": 1, + "description": "Total channels the radio can store." + }, + "max_zones": { + "type": "integer", + "minimum": 0, + "description": "Zones the radio can store. 0 means the radio has no zone concept." + }, + "max_channels_per_zone": { + "type": "integer", + "minimum": 1, + "description": "Channels a single zone can hold." + }, + "max_scan_lists": { + "type": "integer", + "minimum": 0, + "description": "Scan lists the radio can store. Omit when unknown." + }, + "max_contacts": { + "type": "integer", + "minimum": 0, + "description": "Contacts the radio can store. Omit when unknown." + }, + "max_dmr_ids": { + "type": "integer", + "minimum": 0, + "description": "Distinct DMR radio IDs the radio can store." + } + } + }, + "bands": { + "type": "array", + "description": "Frequency ranges the radio can tune, in MHz. A channel whose RX or TX frequency falls outside every listed band cannot work on this radio. Omit when unknown; validation is then skipped rather than assumed.", + "minItems": 1, + "items": { + "type": "object", + "required": ["min_mhz", "max_mhz"], + "additionalProperties": false, + "properties": { + "min_mhz": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Inclusive lower bound in MHz." + }, + "max_mhz": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Inclusive upper bound in MHz." + }, + "name": { + "type": "string", + "description": "Optional label, e.g. 'VHF' or 'UHF'." + }, + "rx_only": { + "type": "boolean", + "default": false, + "description": "Radio can receive but not transmit in this range (e.g. a broadcast or airband RX span)." + } + } + } + }, + "modes": { + "type": "array", + "description": "Channel modes the radio supports. Omit when unknown; validation is then skipped.", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["FM", "DMR"] + } + }, + "bandwidths_khz": { + "type": "array", + "description": "Analog FM channel bandwidths the radio supports, in kHz. Omit when unknown; validation is then skipped.", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "notes": { + "type": "string", + "description": "Free-form provenance or caveats, e.g. where the figures were sourced." + } + } +} diff --git a/src/codeplugger/profile.py b/src/codeplugger/profile.py index ebd04a7..fc9606e 100644 --- a/src/codeplugger/profile.py +++ b/src/codeplugger/profile.py @@ -13,6 +13,9 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2] DEFAULT_SCHEMA_PATH = PROJECT_ROOT / "schemas" / "profile-0.1.schema.json" +DEFAULT_CAPABILITIES_SCHEMA_PATH = ( + PROJECT_ROOT / "schemas" / "capabilities-0.2.schema.json" +) DEFAULT_RADIO_ROOT = PROJECT_ROOT / "radios" @@ -66,6 +69,98 @@ def _assignment_channel_counts( return channel_counts, ambiguous +def _band_label(band: Mapping[str, Any]) -> str: + name = band.get("name") + span = f"{band['min_mhz']}-{band['max_mhz']} MHz" + return f"{name} ({span})" if name else span + + +def _frequency_in_bands( + freq_mhz: float, bands: Sequence[Mapping[str, Any]], *, transmit: bool +) -> bool: + """True when the frequency falls inside any usable band. + + A band marked ``rx_only`` satisfies receive checks but never transmit + checks. + """ + + for band in bands: + if transmit and band.get("rx_only", False): + continue + if band["min_mhz"] <= freq_mhz <= band["max_mhz"]: + return True + return False + + +def _check_radio_support( + capabilities: Mapping[str, Any], + documents: Sequence[Any], + selected: set[str], +) -> None: + """Verify selected channels are physically usable on the target radio. + + Each check is skipped when the corresponding capability is absent, so an + incomplete capabilities file degrades to today's behavior instead of + producing false failures. + """ + + bands = capabilities.get("bands") + modes = capabilities.get("modes") + bandwidths = capabilities.get("bandwidths_khz") + if not (bands or modes or bandwidths): + return + + radio_name = capabilities["name"] + mode_set = {str(mode).upper() for mode in modes} if modes else None + + for document in documents: + reference = document.reference + chains = {chain.id: chain for chain in reference.rf_chains} + for assignment in reference.assignments: + if assignment.id not in selected: + continue + chain = chains.get(assignment.rf_chain_id) + if chain is None: + continue + label = assignment.channel_name or assignment.id + + if bands: + endpoints = [] + if getattr(chain, "rx", None) is not None: + endpoints.append(("RX", chain.rx.freq_mhz, False)) + if getattr(chain, "tx", None) is not None: + endpoints.append(("TX", chain.tx.freq_mhz, True)) + for direction, freq_mhz, transmit in endpoints: + if freq_mhz is None: + continue + if not _frequency_in_bands(freq_mhz, bands, transmit=transmit): + supported = ", ".join(_band_label(band) for band in bands) + raise ProfileValidationError( + f"channel '{label}' {direction} {freq_mhz} MHz is " + f"outside the bands supported by {radio_name} " + f"({supported})" + ) + + mode = getattr(getattr(chain, "mode", None), "type", None) + if mode_set and mode and str(mode).upper() not in mode_set: + raise ProfileValidationError( + f"channel '{label}' uses mode {mode}, which " + f"{radio_name} does not support " + f"({', '.join(sorted(mode_set))})" + ) + + if bandwidths and getattr(chain, "tx", None) is not None: + bandwidth_khz = getattr(chain.tx, "bandwidth_khz", None) + if bandwidth_khz is not None and not any( + abs(bandwidth_khz - supported) < 1e-6 for supported in bandwidths + ): + allowed = ", ".join(str(value) for value in bandwidths) + raise ProfileValidationError( + f"channel '{label}' uses {bandwidth_khz} kHz bandwidth, " + f"which {radio_name} does not support ({allowed} kHz)" + ) + + def _load_and_validate_profile( profile_path: Path, ssrf_roots: Sequence[Path], @@ -91,6 +186,18 @@ def _load_and_validate_profile( if not radio_path.is_file(): raise ProfileValidationError(f"unknown radio '{radio_id}'") capabilities = json.loads(radio_path.read_text(encoding="utf-8")) + if DEFAULT_CAPABILITIES_SCHEMA_PATH.is_file(): + capabilities_schema = json.loads( + DEFAULT_CAPABILITIES_SCHEMA_PATH.read_text(encoding="utf-8") + ) + capability_errors = sorted( + Draft202012Validator(capabilities_schema).iter_errors(capabilities), + key=lambda error: list(error.path), + ) + if capability_errors: + raise ProfileValidationError( + f"{radio_path}: {_format_schema_errors(capability_errors)}" + ) try: from ssrf import resolve_ssrf_roots @@ -146,6 +253,8 @@ def _load_and_validate_profile( f"profile expands to {total_channels} channels; " f"radio limit is {limits['max_channels']}" ) + + _check_radio_support(capabilities, documents, selected) return profile, documents