"""Validation and normalization helpers for run configuration payloads.""" from __future__ import annotations import logging from typing import Any from python_app.models.run_config_schema import ( ComboModel, ControlButtonModel, GprModel, RadarSweepModel, RingEndpointModel, SwitchModel, ) logger = logging.getLogger(__name__) # Wire-format bounds shared with the C++ pipeline. The ring header stores the # slot size as a uint32, and capacity * slot_size must address into a single # shared-memory mapping, so reject values the C++ side cannot represent. _UINT32_MAX = (1 << 32) - 1 _RING_SEGMENT_MAX_BYTES = 1 << 40 # 1 TiB upper bound on a single ring mapping. # Defensive ceiling so a malformed combos string cannot expand into a list that # stalls the GUI or the downstream acquisition loop. _MAX_COMBOS = 4096 def _require_int(payload: dict[str, Any], key: str, default: int) -> int: """Read a strict JSON integer, treating an explicit ``null`` as 'use default'. Accepts only a genuine JSON integer (not bool, not float, not numeric string), because silently truncating ``5.7`` or parsing ``"5"`` would hide a malformed config. Mirrors ``run_config_codec._read_int`` so every config integer reads identically. """ value = payload.get(key, default) if value is None: # explicit JSON null -> use the default, never coerce return default if isinstance(value, bool) or not isinstance(value, int): raise ValueError(f"{key} must be a JSON integer") return value def _require_str(payload: dict[str, Any], key: str, default: str) -> str: """Read a string field, rejecting JSON arrays/objects with a named ValueError.""" value = payload.get(key, default) if value is None: # explicit JSON null -> use the default, never coerce to "None" return default if isinstance(value, (dict, list)): raise ValueError(f"{key} must be a JSON string") return str(value) def _require_bool(payload: dict[str, Any], key: str, default: bool) -> bool: """Read a boolean field, rejecting non-boolean JSON types with a named ValueError.""" value = payload.get(key, default) if value is None: # explicit JSON null -> use the default return default if not isinstance(value, bool): raise ValueError(f"{key} must be a JSON boolean") return value def load_switch_payload( payload: dict[str, Any], target: SwitchModel, ) -> None: """Populate switch model from payload preserving defaults for missing values.""" # Scalar reads reject array/object JSON types as ValueError (not TypeError). target.name = _require_str(payload, "name", target.name) target.driver_mode = _require_str(payload, "driver_mode", target.driver_mode) target.driver = _require_str(payload, "driver", target.driver) target.radar_port = _require_int(payload, "radar_port", target.radar_port) target.positions = _require_int(payload, "positions", target.positions) target.default_position = _require_int(payload, "default_position", target.default_position) target.gpio_chip = _require_str(payload, "gpio_chip", target.gpio_chip) target.pin_a = _require_int(payload, "pin_a", target.pin_a) target.pin_b = _require_int(payload, "pin_b", target.pin_b) target.invert_logic = _require_bool(payload, "invert_logic", target.invert_logic) def load_control_button_payload( payload: dict[str, Any], target: ControlButtonModel, ) -> None: """Populate control-button model from payload preserving defaults.""" # Scalar reads reject array/object JSON types as ValueError (not TypeError). target.enabled = _require_bool(payload, "enabled", target.enabled) target.gpio_chip = _require_str(payload, "gpio_chip", target.gpio_chip) target.pin = _require_int(payload, "pin", target.pin) target.active_low = _require_bool(payload, "active_low", target.active_low) target.bias = _require_str(payload, "bias", target.bias) target.debounce_ms = _require_int(payload, "debounce_ms", target.debounce_ms) target.action = _require_str(payload, "action", target.action) def load_ring_payload(payload: dict[str, Any], target: RingEndpointModel) -> None: """Populate ring endpoint model from payload preserving defaults. Validates the resulting ring sizing before returning, so a bad config fails here (on GUI save and at config load) instead of crashing the C++ ring allocator at boot. """ # Scalar reads reject array/object JSON types as ValueError (not TypeError). target.name = _require_str(payload, "name", target.name) target.capacity = _require_int(payload, "capacity", target.capacity) target.slot_size_bytes = _require_int(payload, "slot_size_bytes", target.slot_size_bytes) validate_ring_endpoint(target) def validate_ring_endpoint(ring: RingEndpointModel) -> None: """Validate ring sizing against the constraints the C++ allocator requires. Raises ValueError naming the offending ring when capacity or slot size is non-positive, the slot size overflows the uint32 wire field, or the segment would exceed the maximum single mapping. """ ring_name = ring.name or "ring" if ring.capacity <= 0: raise ValueError(f"rings.{ring_name}.capacity must be > 0") if ring.slot_size_bytes <= 0: raise ValueError(f"rings.{ring_name}.slot_size_bytes must be > 0") if ring.slot_size_bytes > _UINT32_MAX: raise ValueError(f"rings.{ring_name}.slot_size_bytes exceeds the uint32 wire limit") # Overflow-safe: compare against the ceiling without ever forming the full # product, so an oversized capacity cannot wrap a fixed-width index. if ring.capacity > _RING_SEGMENT_MAX_BYTES // ring.slot_size_bytes: raise ValueError( f"rings.{ring_name} capacity * slot_size_bytes exceeds the maximum ring segment size" ) def validate_sweep_model(sweep: RadarSweepModel) -> None: """Validate radar sweep bounds (point count and frequency span). Runs in Python so a bad sweep fails on GUI save and at config load rather than aborting the C++ acquisition process at boot. Raises ValueError when ``points`` is non-integral or non-positive, or when ``stop_hz`` does not exceed ``start_hz``. """ # points must be a positive, integral count of frequency samples. points = sweep.points if isinstance(points, bool) or not isinstance(points, int): raise ValueError("radar.sweep.points must be an integer") if points <= 0: raise ValueError("radar.sweep.points must be > 0") if float(sweep.stop_hz) <= float(sweep.start_hz): raise ValueError("radar.sweep.stop_hz must be > radar.sweep.start_hz") def validate_gpr_model( gpr: GprModel, *, input_switch_positions: int, output_switch_positions: int, sweep: RadarSweepModel | None = None, ) -> None: """Validate stable GPR config against current switch dimensions. Checks that the relative permittivity is positive and that every tx/rx geometry entry indexes a valid, non-duplicate switch position. When ``sweep`` is supplied (the load and GUI/save paths share this chokepoint), its bounds are validated here too so sweep failures surface alongside the GPR checks instead of as a C++ boot crash. """ if sweep is not None: validate_sweep_model(sweep) if float(gpr.relative_permittivity) <= 0.0: raise ValueError("gpr.relative_permittivity must be > 0") seen_output_positions: set[int] = set() for entry in gpr.tx_geometry: output_pos = int(entry.output_pos) if output_pos < 0 or output_pos >= int(output_switch_positions): raise ValueError("gpr.tx_geometry output_pos is out of range") if output_pos in seen_output_positions: raise ValueError("gpr.tx_geometry contains duplicate output_pos") seen_output_positions.add(output_pos) seen_input_positions: set[int] = set() for entry in gpr.rx_geometry: input_pos = int(entry.input_pos) if input_pos < 0 or input_pos >= int(input_switch_positions): raise ValueError("gpr.rx_geometry input_pos is out of range") if input_pos in seen_input_positions: raise ValueError("gpr.rx_geometry contains duplicate input_pos") seen_input_positions.add(input_pos) def validate_combos( combos: list[ComboModel], *, input_positions: int, output_positions: int, ) -> None: """Validate run combos against the configured switch dimensions. Each combo's input/output must index a real switch position, and no ``input:output`` pair may repeat — an out-of-range or duplicate combo is a config error that would otherwise produce missing or doubled traces downstream. """ seen: set[tuple[int, int]] = set() for combo in combos: if not 0 <= int(combo.input) < int(input_positions): raise ValueError( f"run.combos input {combo.input} is out of range [0, {input_positions})" ) if not 0 <= int(combo.output) < int(output_positions): raise ValueError( f"run.combos output {combo.output} is out of range [0, {output_positions})" ) pair = (int(combo.input), int(combo.output)) if pair in seen: raise ValueError(f"run.combos contains duplicate combo {combo.input}:{combo.output}") seen.add(pair) def parse_combos_from_text(text: str) -> list[ComboModel]: """Parse a UI combos string in ``input:output,input:output`` format. Returns an empty list for blank input. Raises ValueError (naming the offending pair) on malformed syntax, empty sides, non-integer values, more than ``_MAX_COMBOS`` entries, or a non-blank string that yields no combos. """ cleaned = text.strip() if not cleaned: return [] combos: list[ComboModel] = [] for item in cleaned.split(","): pair = item.strip() if not pair: continue if ":" not in pair: raise ValueError(f"Invalid combo syntax: {pair!r}. Expected input:output") # Cap the combo count so a pathological string cannot expand into a list # large enough to stall the GUI or the acquisition loop. if len(combos) >= _MAX_COMBOS: raise ValueError(f"Too many combos: limit is {_MAX_COMBOS}") input_text, output_text = (side.strip() for side in pair.split(":", 1)) # Reject empty sides and re-raise non-integer values naming the pair/side. if not input_text: raise ValueError(f"Invalid combo {pair!r}: input side is empty") if not output_text: raise ValueError(f"Invalid combo {pair!r}: output side is empty") try: input_value = int(input_text) except ValueError as exc: raise ValueError(f"Invalid combo {pair!r}: input {input_text!r} is not an integer") from exc try: output_value = int(output_text) except ValueError as exc: raise ValueError(f"Invalid combo {pair!r}: output {output_text!r} is not an integer") from exc combos.append(ComboModel(input=input_value, output=output_value)) if not combos: raise ValueError("No valid combos were provided") logger.debug("Parsed %d combo(s) from UI text", len(combos)) return combos