diff --git a/radios/baofeng_dm32/capabilities.json b/radios/baofeng_dm32/capabilities.json index f76b934..dd2373f 100644 --- a/radios/baofeng_dm32/capabilities.json +++ b/radios/baofeng_dm32/capabilities.json @@ -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": [ { @@ -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.*." } diff --git a/schemas/capabilities-0.2.schema.json b/schemas/capabilities-0.2.schema.json index 973324f..d2588f4 100644 --- a/schemas/capabilities-0.2.schema.json +++ b/schemas/capabilities-0.2.schema.json @@ -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": { @@ -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": { @@ -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": { @@ -133,7 +155,10 @@ "uniqueItems": true, "items": { "type": "string", - "enum": ["FM", "DMR"] + "enum": [ + "FM", + "DMR" + ] } }, "bandwidths_khz": { @@ -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." + } + } + } } } diff --git a/src/codeplugger/profile.py b/src/codeplugger/profile.py index 085eba1..e4beb86 100644 --- a/src/codeplugger/profile.py +++ b/src/codeplugger/profile.py @@ -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, @@ -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() @@ -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) diff --git a/tests/test_profile.py b/tests/test_profile.py index 21a28cb..a270acd 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -597,3 +597,156 @@ def test_resolved_codeplug_includes_instance_registry_metadata() -> None: "label": "Green test radio", "firmware": "TEST.01", } + + +def _write_radio_with_firmware_limits(root: Path) -> None: + """A radio whose contact capacity changes across firmware versions. + + Mirrors the DM-32UV ROW line: 048 stores more contacts than 049. + """ + + radio = root / "test_radio" + radio.mkdir(parents=True, exist_ok=True) + (radio / "capabilities.json").write_text( + json.dumps( + { + "id": "test_radio", + "name": "Test radio", + "capabilities_version": "0.2", + "limits": { + "max_channels": 2, + "max_zones": 1, + "max_channels_per_zone": 2, + "max_contacts": 50000, + }, + "firmware_limits": { + "FW.048": {"max_contacts": 150000}, + "FW.049": {"max_contacts": 50000}, + "FW.050": {"max_zones": 1, "max_channels": 1}, + }, + } + ), + encoding="utf-8", + ) + + +def _resolve(firmware: str | None) -> dict: + """Resolve effective limits for ``firmware`` against the fixture radio.""" + + from codeplugger.profile import _resolve_firmware_limits + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_radio_with_firmware_limits(root / "radios") + capabilities = json.loads( + (root / "radios" / "test_radio" / "capabilities.json").read_text( + encoding="utf-8" + ) + ) + limits, source = _resolve_firmware_limits(capabilities, firmware) + return {"limits": limits, "source": source} + + +def test_declared_firmware_selects_its_own_limits() -> None: + """048 gets the larger contact ceiling; 049 gets the smaller one.""" + + assert _resolve("FW.048")["limits"]["max_contacts"] == 150000 + assert _resolve("FW.049")["limits"]["max_contacts"] == 50000 + assert "FW.048" in _resolve("FW.048")["source"] + + +def test_unknown_firmware_falls_back_to_the_conservative_floor() -> None: + """An unverified radio must not validate against 048-only capacity. + + This is the safety property: absent firmware information, take the minimum + across every declared firmware rather than the base value. + """ + + resolved = _resolve(None) + assert resolved["limits"]["max_contacts"] == 50000 + # FW.050 declares a *smaller* channel ceiling, so the floor must pick it up + # even though the base limits allow 2. + assert resolved["limits"]["max_channels"] == 1 + assert "conservative" in resolved["source"] + + +def test_firmware_absent_from_overrides_is_treated_as_unknown() -> None: + """A radio running firmware we have not characterized gets the floor.""" + + resolved = _resolve("FW.999") + assert resolved["limits"]["max_contacts"] == 50000 + assert resolved["limits"]["max_channels"] == 1 + assert "FW.999" in resolved["source"] + assert "conservative" in resolved["source"] + + +def test_radio_without_firmware_limits_is_unchanged() -> None: + """Radios that declare no firmware_limits keep today's behavior exactly.""" + + from codeplugger.profile import _resolve_firmware_limits + + capabilities = { + "id": "test_radio", + "name": "Test radio", + "limits": {"max_channels": 2, "max_zones": 1, "max_channels_per_zone": 2}, + } + limits, source = _resolve_firmware_limits(capabilities, None) + assert limits == capabilities["limits"] + assert source == "radio limit" + + +def test_instance_firmware_drives_enforcement_end_to_end() -> None: + """The registry's firmware field must reach the limit check. + + FW.050 declares max_channels 1, so a two-channel profile fails only when + the instance's firmware is consulted. + """ + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + profile = root / "profile.yml" + _write_profile(profile, ["asg_one", "asg_two"]) + _write_radio_with_firmware_limits(root / "radios") + _write_ssrf(root / "ssrf") + registry = _write_instance_registry( + root, + { + "dm32_green_01": { + "radio": "test_radio", + "firmware": "FW.050", + } + }, + ) + + with pytest.raises(ProfileValidationError, match="firmware FW.050"): + load_and_validate_profile( + profile, + [root / "ssrf"], + radio_root=root / "radios", + instance_registry_path=registry, + ) + + +def test_dm32_declares_firmware_dependent_contact_limits() -> None: + """The real DM-32 file records the 048/049 contact split. + + Base max_contacts must be the SMALLER value so an unknown-firmware + validation cannot pass a profile that only fits on 048. + """ + + capabilities = json.loads( + ( + Path(__file__).resolve().parents[1] + / "radios" + / "baofeng_dm32" + / "capabilities.json" + ).read_text(encoding="utf-8") + ) + firmware_limits = capabilities["firmware_limits"] + + assert firmware_limits["DM32.01.L01.048"]["max_contacts"] == 150000 + assert firmware_limits["DM32.01.01.049"]["max_contacts"] == 50000 + assert capabilities["limits"]["max_contacts"] == 50000 + assert capabilities["limits"]["max_contacts"] == min( + override["max_contacts"] for override in firmware_limits.values() + )