Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions radios/baofeng_dm32/capabilities.json
Original file line number Diff line number Diff line change
@@ -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
}
}
"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."
}
13 changes: 12 additions & 1 deletion radios/retevis_matetalk_p4/capabilities.json
Original file line number Diff line number Diff line change
@@ -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'."
}
119 changes: 119 additions & 0 deletions schemas/capabilities-0.2.schema.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
109 changes: 109 additions & 0 deletions src/codeplugger/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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],
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down
Loading