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: 19 additions & 4 deletions radios/baofeng_dm32/capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@
"max_channel_name_chars": 16,
"max_zone_name_chars": 16,
"max_scan_list_name_chars": 10,
"max_contact_name_chars": 16
"max_contact_name_chars": 16,
"max_contacts": 50000
},
"firmware_limits": {
"DM32.01.L01.048": {
"max_contacts": 150000
},
"DM32.01.01.049": {
"max_contacts": 50000
}
},
"bands": [
{
Expand All @@ -27,7 +36,13 @@
"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. max_channels_per_zone is 64, confirmed in OEM CPS v1.59 (channels 1-64 per zone); it is NOT 250, which is the max_zones count. max_channels_per_scan_list is 15, derived from the on-radio record layout rather than the CPS UI: NeonPlug's DM-32UV codec (src/radios/dm32uv/structures.ts) places the scan-list channel array at +0x1A with the next field at +0x38, so 30 bytes = 15 x uint16, and both its encoder and decoder hard-loop i < 15 with channelCount at +0x0B documented 0-15. The OEM CPS appears to offer 16 because its available-channels list includes a 'Current Channel' entry registered at index 0 in addition to 15 definable members; do not raise this to 16. Exceeding 15 is silently destructive: NeonPlug clamps to Math.min(len, 15) but only writes the 0x0000 terminator when channelCount < 15, so a full 15-member list emits no terminator and the radio parses past the end of the record into the adjacent field, which makes every scan list unselectable rather than just the oversized one. RX group limits (32 lists, 32 talkgroups each) confirmed in OEM CPS v1.59; for more than 32 talkgroups on one channel use Group Call Match / promiscuous mode instead."
"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. max_channels_per_zone is 64, confirmed in OEM CPS v1.59 (channels 1-64 per zone); it is NOT 250, which is the max_zones count. max_channels_per_scan_list is 15, derived from the on-radio record layout rather than the CPS UI: NeonPlug's DM-32UV codec (src/radios/dm32uv/structures.ts) places the scan-list channel array at +0x1A with the next field at +0x38, so 30 bytes = 15 x uint16, and both its encoder and decoder hard-loop i < 15 with channelCount at +0x0B documented 0-15. The OEM CPS appears to offer 16 because its available-channels list includes a 'Current Channel' entry registered at index 0 in addition to 15 definable members; do not raise this to 16. Exceeding 15 is silently destructive: NeonPlug clamps to Math.min(len, 15) but only writes the 0x0000 terminator when channelCount < 15, so a full 15-member list emits no terminator and the radio parses past the end of the record into the adjacent field, which makes every scan list unselectable rather than just the oversized one. RX group limits (32 lists, 32 talkgroups each) confirmed in OEM CPS v1.59; for more than 32 talkgroups on one channel use Group Call Match / promiscuous mode instead. CSV contact capacity is firmware-dependent on the ROW line: DM32.01.L01.048 removed the record function and raised CSV contacts to 150,000; DM32.01.01.049 restored record and returned to 50,000 (memory/2026-08-07.md, M7OCM archive release notes). Base limits.max_contacts is therefore set to the SMALLER 50,000 so that validating without a known firmware cannot pass a profile that only fits on 048. Upgrading 048 -> 049 is a capacity regression: check CSV size first. Not verified for the HR Vocoder line (DM32.00.*), which has no 049 at all, or for DM32.NRF.*."
}
108 changes: 104 additions & 4 deletions schemas/capabilities-0.2.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
"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"],
"required": [
"id",
"name",
"limits"
],
"additionalProperties": false,
"properties": {
"id": {
Expand All @@ -25,7 +29,11 @@
"limits": {
"type": "object",
"description": "Hard structural limits enforced against a resolved profile.",
"required": ["max_channels", "max_zones", "max_channels_per_zone"],
"required": [
"max_channels",
"max_zones",
"max_channels_per_zone"
],
"additionalProperties": false,
"properties": {
"max_channels": {
Expand Down Expand Up @@ -95,13 +103,27 @@
}
}
},
"firmware_limits": {
"type": "object",
"description": "Per-firmware limit overrides. Keys are firmware version strings as reported by the radio (e.g. \"DM32.01.01.049\"). A profile validated without a known firmware uses `limits` intersected with every override here, so an unknown firmware cannot validate against a capacity no shipped firmware provides.",
"propertyNames": {
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"minProperties": 1,
"additionalProperties": {
"$ref": "#/$defs/firmwareLimitOverrides"
}
},
"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"],
"required": [
"min_mhz",
"max_mhz"
],
"additionalProperties": false,
"properties": {
"min_mhz": {
Expand Down Expand Up @@ -133,7 +155,10 @@
"uniqueItems": true,
"items": {
"type": "string",
"enum": ["FM", "DMR"]
"enum": [
"FM",
"DMR"
]
}
},
"bandwidths_khz": {
Expand All @@ -150,5 +175,80 @@
"type": "string",
"description": "Free-form provenance or caveats, e.g. where the figures were sourced."
}
},
"$defs": {
"firmwareLimitOverrides": {
"type": "object",
"description": "Limit overrides for one firmware version. Same keys as `limits`; only the keys that actually change need to be listed. Values replace the base `limits` value, they do not merge per-key.",
"additionalProperties": false,
"minProperties": 1,
"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_channels_per_scan_list": {
"type": "integer",
"minimum": 1,
"description": "Channels a single scan list can hold. This is the on-radio storage capacity, which may be smaller than the count a CPS UI appears to offer. Omit when unknown."
},
"max_rx_group_lists": {
"type": "integer",
"minimum": 0,
"description": "DMR RX group lists the radio can store. Omit when unknown."
},
"max_talkgroups_per_rx_group_list": {
"type": "integer",
"minimum": 1,
"description": "Talkgroups a single DMR RX group list can hold. 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."
},
"max_channel_name_chars": {
"type": "integer",
"minimum": 1,
"description": "Characters a channel display name can hold. Names longer than this may be silently truncated by the CPS or on-radio encoder rather than rejected. Omit when unknown."
},
"max_zone_name_chars": {
"type": "integer",
"minimum": 1,
"description": "Characters a zone name can hold. Omit when unknown."
},
"max_scan_list_name_chars": {
"type": "integer",
"minimum": 1,
"description": "Characters a scan list name can hold. Omit when unknown."
},
"max_contact_name_chars": {
"type": "integer",
"minimum": 1,
"description": "Characters a contact name can hold. Omit when unknown."
}
}
}
}
}
62 changes: 58 additions & 4 deletions src/codeplugger/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,56 @@ def _assignment_channel_counts(
return channel_counts, ambiguous


def _resolve_firmware_limits(
capabilities: Mapping[str, Any],
firmware: str | None,
) -> tuple[dict[str, int], str]:
"""Return the effective limits for ``firmware`` plus a description of them.

Some limits change across firmware versions on the same radio. The DM-32UV
ROW line is the known case: ``DM32.01.L01.048`` stores 150,000 CSV contacts
but has no record function, and ``DM32.01.01.049`` restores record and drops
back to 50,000. A profile that only fits on 048 must not validate clean
against a radio running 049.

Resolution:

* A firmware with an entry in ``firmware_limits`` uses ``limits`` updated by
that entry.
* A known radio with an *unknown* firmware uses the **most conservative**
value across the base limits and every declared override, so an unverified
radio cannot validate against a capacity no shipped firmware provides.
* A radio declaring no ``firmware_limits`` behaves exactly as before.
"""

base = dict(capabilities["limits"])
overrides: Mapping[str, Mapping[str, int]] = capabilities.get(
"firmware_limits", {}
)
if not overrides:
return base, "radio limit"

if firmware is not None and firmware in overrides:
base.update(overrides[firmware])
return base, f"limit for firmware {firmware}"

# Unknown or undeclared firmware: take the floor of every possibility so
# validation cannot pass something that fits on no shipped firmware.
for override in overrides.values():
for key, value in override.items():
current = base.get(key)
base[key] = value if current is None else min(current, value)

if firmware is None:
reason = "conservative limit across all known firmware (no firmware declared)"
else:
reason = (
f"conservative limit across all known firmware "
f"(firmware {firmware} is not declared in firmware_limits)"
)
return base, reason


def _check_name_length(
limits: Mapping[str, int],
limit_key: str,
Expand Down Expand Up @@ -297,11 +347,15 @@ def _load_and_validate_profile(
documents = resolve_ssrf_roots(ssrf_roots)
channel_counts, ambiguous_assignments = _assignment_channel_counts(documents)
zones = profile["zones"]
limits: Mapping[str, int] = capabilities["limits"]
firmware = None
if instance_metadata is not None:
firmware = instance_metadata.get("firmware")
limits, limit_source = _resolve_firmware_limits(capabilities, firmware)

if len(zones) > limits["max_zones"]:
raise ProfileValidationError(
f"profile has {len(zones)} zones; radio limit is {limits['max_zones']}"
f"profile has {len(zones)} zones; "
f"{limit_source} is {limits['max_zones']}"
)

zone_ids: set[str] = set()
Expand Down Expand Up @@ -333,14 +387,14 @@ def _load_and_validate_profile(
if zone_channel_count > limits["max_channels_per_zone"]:
raise ProfileValidationError(
f"zone '{zone['name']}' expands to {zone_channel_count} channels; "
f"radio limit is {limits['max_channels_per_zone']}"
f"{limit_source} is {limits['max_channels_per_zone']}"
)
total_channels += zone_channel_count

if total_channels > limits["max_channels"]:
raise ProfileValidationError(
f"profile expands to {total_channels} channels; "
f"radio limit is {limits['max_channels']}"
f"{limit_source} is {limits['max_channels']}"
)

_check_radio_support(capabilities, documents, selected)
Expand Down
Loading
Loading