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