Source code for pycsamt.iot.schemas

"""Formal telemetry payload schemas.

Raw IoT payloads are free-form dictionaries, which is convenient but
fragile: the same quantity may arrive as ``battery_v``, ``battery_voltage``,
or ``voltage``. These schemas define a canonical field set per packet
kind, absorb common key aliases, and validate ranges, so downstream
monitoring and provenance code can rely on stable keys.

Each schema is a light dataclass exposing:

* ``from_mapping(payload)`` — tolerant parser that pulls known aliases
  into canonical fields and preserves anything else under ``extra``.
* ``as_dict(drop_none=...)`` — flat, serialisable payload with canonical
  keys merged with ``extra``.

Use :func:`validate_payload` / :func:`parse_payload` to route a payload
through the schema registered for a given :class:`~pycsamt.iot.core.PacketKind`.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import Enum
from typing import Any

from ..api.property import PyCSAMTObject
from . import _common as _c
from .core import PacketKind

__all__ = [
    "EventSeverity",
    "TelemetryPayload",
    "HealthPayload",
    "QCPayload",
    "PowerPayload",
    "SourcePayload",
    "SyncPayload",
    "EventPayload",
    "AcquisitionPayload",
    "PAYLOAD_SCHEMAS",
    "schema_for",
    "parse_payload",
    "validate_payload",
]


[docs] class EventSeverity(str, Enum): """Severity levels for event telemetry.""" INFO = "info" WARNING = "warning" ERROR = "error" CRITICAL = "critical"
def _first(mapping: Mapping[str, Any], aliases: tuple[str, ...]) -> Any: """Return the first present, non-null value among *aliases*.""" for key in aliases: if key in mapping and mapping[key] is not None: return mapping[key] return None def _known_keys(*alias_groups: tuple[str, ...]) -> set[str]: return {key for group in alias_groups for key in group}
[docs] @dataclass class TelemetryPayload(PyCSAMTObject): """Base class for canonical telemetry payloads.""" #: Canonical packet kind the schema describes. Overridden per subclass. kind: PacketKind = PacketKind.DATA station: str | None = None extra: dict[str, Any] = field(default_factory=dict) # ---- shared field alias groups ------------------------------------- _STATION_ALIASES = ("station", "site", "station_id", "station_name") def _base_validate(self) -> None: self.station = _c.as_optional_str(self.station, "station") if not isinstance(self.extra, dict): self.extra = dict(self.extra or {}) @classmethod def _consume_station(cls, data: dict[str, Any]) -> str | None: return _c.as_optional_str( _first(data, cls._STATION_ALIASES), "station" )
[docs] def as_dict(self, *, drop_none: bool = False) -> dict[str, Any]: """Return a flat payload dictionary with canonical keys.""" raise NotImplementedError # pragma: no cover - overridden
def _finish( self, canonical: dict[str, Any], *, drop_none: bool, ) -> dict[str, Any]: out = dict(self.extra) for key, value in canonical.items(): if drop_none and value is None: continue out[key] = value return out
[docs] @dataclass class HealthPayload(TelemetryPayload): """Device-health telemetry (battery, temperature, link quality).""" kind: PacketKind = PacketKind.HEALTH battery_v: float | None = None temperature_c: float | None = None uptime_s: float | None = None free_storage_mb: float | None = None rssi_dbm: float | None = None firmware: str | None = None _BATTERY = ( "battery_v", "battery_voltage", "battery_voltage_v", "voltage", "batt_v", ) _TEMPERATURE = ("temperature_c", "temperature", "temp_c", "temp") _UPTIME = ("uptime_s", "uptime", "uptime_seconds") _STORAGE = ("free_storage_mb", "storage_mb", "free_mb") _RSSI = ("rssi_dbm", "rssi", "signal_dbm") _FIRMWARE = ("firmware", "firmware_version", "fw") def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: self._base_validate() self.battery_v = _c.as_optional_finite_float( self.battery_v, "battery_v" ) self.temperature_c = _c.as_optional_finite_float( self.temperature_c, "temperature_c" ) if self.uptime_s is not None: self.uptime_s = _c.as_nonnegative(self.uptime_s, "uptime_s") self.free_storage_mb = _c.as_optional_finite_float( self.free_storage_mb, "free_storage_mb" ) self.rssi_dbm = _c.as_optional_finite_float(self.rssi_dbm, "rssi_dbm") self.firmware = _c.as_optional_str(self.firmware, "firmware")
[docs] @classmethod def from_mapping(cls, payload: Mapping[str, Any]) -> HealthPayload: data = dict(payload or {}) known = _known_keys( cls._STATION_ALIASES, cls._BATTERY, cls._TEMPERATURE, cls._UPTIME, cls._STORAGE, cls._RSSI, cls._FIRMWARE, ) return cls( station=cls._consume_station(data), battery_v=_first(data, cls._BATTERY), temperature_c=_first(data, cls._TEMPERATURE), uptime_s=_first(data, cls._UPTIME), free_storage_mb=_first(data, cls._STORAGE), rssi_dbm=_first(data, cls._RSSI), firmware=_first(data, cls._FIRMWARE), extra={k: v for k, v in data.items() if k not in known}, )
[docs] def as_dict(self, *, drop_none: bool = False) -> dict[str, Any]: return self._finish( dict( station=self.station, battery_v=self.battery_v, temperature_c=self.temperature_c, uptime_s=self.uptime_s, free_storage_mb=self.free_storage_mb, rssi_dbm=self.rssi_dbm, firmware=self.firmware, ), drop_none=drop_none, )
[docs] @dataclass class QCPayload(TelemetryPayload): """Edge quality-control telemetry for one acquisition window.""" kind: PacketKind = PacketKind.QC accepted: bool | None = None decision: str | None = None snr_db: float | None = None finite_coverage: float | None = None spike_fraction: float | None = None rms: float | None = None method: str | None = None channels: list[str] = field(default_factory=list) frequency_band_hz: tuple[float, float] | None = None reasons: list[str] = field(default_factory=list) _ACCEPTED = ("accepted", "edge_accepted", "qc_accepted", "ok") _DECISION = ("decision", "edge_decision", "qc_decision") _SNR = ("snr_db", "snr", "channel_snr_db") _COVERAGE = ("finite_coverage", "coverage") _SPIKE = ("spike_fraction", "spikes", "spike_frac") _RMS = ("rms", "rms_value") _METHOD = ("method", "survey_method", "em_method") _CHANNELS = ("channels", "channel") _BAND = ("frequency_band_hz", "band_hz", "freq_band_hz") _REASONS = ("reasons", "qc_reasons") def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: self._base_validate() if self.accepted is not None: self.accepted = _c.as_bool(self.accepted) self.decision = _c.as_optional_str(self.decision, "decision") if self.decision is not None: self.decision = self.decision.lower() self.snr_db = _c.as_optional_finite_float(self.snr_db, "snr_db") if self.finite_coverage is not None: self.finite_coverage = _c.as_probability( self.finite_coverage, "finite_coverage" ) if self.spike_fraction is not None: self.spike_fraction = _c.as_probability( self.spike_fraction, "spike_fraction" ) self.rms = _c.as_optional_finite_float(self.rms, "rms") self.method = _c.as_optional_str(self.method, "method") if self.method is not None: self.method = self.method.lower() self.channels = _c.as_channel_list(self.channels) self.frequency_band_hz = _normalise_band(self.frequency_band_hz) self.reasons = _as_str_list(self.reasons)
[docs] @classmethod def from_mapping(cls, payload: Mapping[str, Any]) -> QCPayload: data = dict(payload or {}) known = _known_keys( cls._STATION_ALIASES, cls._ACCEPTED, cls._DECISION, cls._SNR, cls._COVERAGE, cls._SPIKE, cls._RMS, cls._METHOD, cls._CHANNELS, cls._BAND, cls._REASONS, ) return cls( station=cls._consume_station(data), accepted=_first(data, cls._ACCEPTED), decision=_first(data, cls._DECISION), snr_db=_first(data, cls._SNR), finite_coverage=_first(data, cls._COVERAGE), spike_fraction=_first(data, cls._SPIKE), rms=_first(data, cls._RMS), method=_first(data, cls._METHOD), channels=_first(data, cls._CHANNELS) or [], frequency_band_hz=_first(data, cls._BAND), reasons=_first(data, cls._REASONS) or [], extra={k: v for k, v in data.items() if k not in known}, )
[docs] def as_dict(self, *, drop_none: bool = False) -> dict[str, Any]: return self._finish( dict( station=self.station, accepted=self.accepted, decision=self.decision, snr_db=self.snr_db, finite_coverage=self.finite_coverage, spike_fraction=self.spike_fraction, rms=self.rms, method=self.method, channels=list(self.channels), frequency_band_hz=( list(self.frequency_band_hz) if self.frequency_band_hz is not None else None ), reasons=list(self.reasons), ), drop_none=drop_none, )
[docs] @dataclass class PowerPayload(TelemetryPayload): """Energy-budget telemetry for a field node.""" kind: PacketKind = PacketKind.POWER battery_v: float | None = None state: str | None = None runtime_days: float | None = None net_wh_per_day: float | None = None solar_w: float | None = None load_w: float | None = None _BATTERY = ("battery_v", "battery_voltage", "voltage") _STATE = ("state", "power_state") _RUNTIME = ("runtime_days", "autonomy_days", "runtime") _NET = ("net_wh_per_day", "net_energy_wh_per_day") _SOLAR = ("solar_w", "solar_power_w", "harvest_w") _LOAD = ("load_w", "load_power_w", "average_power_w") def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: self._base_validate() self.battery_v = _c.as_optional_finite_float( self.battery_v, "battery_v" ) self.state = _c.as_optional_str(self.state, "state") if self.state is not None: self.state = self.state.lower() self.runtime_days = _c.as_optional_finite_float( self.runtime_days, "runtime_days" ) self.net_wh_per_day = _c.as_optional_finite_float( self.net_wh_per_day, "net_wh_per_day" ) self.solar_w = _c.as_optional_finite_float(self.solar_w, "solar_w") self.load_w = _c.as_optional_finite_float(self.load_w, "load_w")
[docs] @classmethod def from_mapping(cls, payload: Mapping[str, Any]) -> PowerPayload: data = dict(payload or {}) known = _known_keys( cls._STATION_ALIASES, cls._BATTERY, cls._STATE, cls._RUNTIME, cls._NET, cls._SOLAR, cls._LOAD, ) return cls( station=cls._consume_station(data), battery_v=_first(data, cls._BATTERY), state=_first(data, cls._STATE), runtime_days=_first(data, cls._RUNTIME), net_wh_per_day=_first(data, cls._NET), solar_w=_first(data, cls._SOLAR), load_w=_first(data, cls._LOAD), extra={k: v for k, v in data.items() if k not in known}, )
[docs] def as_dict(self, *, drop_none: bool = False) -> dict[str, Any]: return self._finish( dict( station=self.station, battery_v=self.battery_v, state=self.state, runtime_days=self.runtime_days, net_wh_per_day=self.net_wh_per_day, solar_w=self.solar_w, load_w=self.load_w, ), drop_none=drop_none, )
[docs] @dataclass class SourcePayload(TelemetryPayload): """Transmitter telemetry for a controlled-source (CSAMT/TDEM) survey. Reported by a transmitter node so the receiver-side QC and provenance know the state of the source that produced each sounding: the injected current and voltage, the frequency currently being transmitted, the grounded-dipole geometry, and the keyed on/off state. """ kind: PacketKind = PacketKind.SOURCE source_id: str | None = None tx_current_a: float | None = None tx_voltage_v: float | None = None tx_frequency_hz: float | None = None tx_power_w: float | None = None dipole_length_m: float | None = None duty_cycle: float | None = None on: bool | None = None offset_m: float | None = None azimuth_deg: float | None = None _SOURCE_ID = ("source_id", "tx_id", "transmitter_id") _CURRENT = ("tx_current_a", "current_a", "tx_current", "current") _VOLTAGE = ("tx_voltage_v", "voltage_v", "tx_voltage") _FREQ = ("tx_frequency_hz", "frequency_hz", "tx_freq_hz", "frequency") _POWER = ("tx_power_w", "power_w", "tx_power") _DIPOLE = ("dipole_length_m", "tx_dipole_length_m", "ab_length_m", "ab_m") _DUTY = ("duty_cycle", "duty") _ON = ("on", "keyed_on", "tx_on", "transmitting") _OFFSET = ("offset_m", "tx_rx_offset_m", "tx_rx_offset", "rx_offset_m") _AZIMUTH = ("azimuth_deg", "tx_azimuth_deg", "bearing_deg") def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: self._base_validate() self.source_id = _c.as_optional_str(self.source_id, "source_id") self.tx_current_a = _c.as_optional_finite_float( self.tx_current_a, "tx_current_a" ) self.tx_voltage_v = _c.as_optional_finite_float( self.tx_voltage_v, "tx_voltage_v" ) self.tx_frequency_hz = _c.as_optional_positive( self.tx_frequency_hz, "tx_frequency_hz" ) self.tx_power_w = _c.as_optional_finite_float( self.tx_power_w, "tx_power_w" ) self.dipole_length_m = _c.as_optional_positive( self.dipole_length_m, "dipole_length_m" ) if self.duty_cycle is not None: self.duty_cycle = _c.as_probability(self.duty_cycle, "duty_cycle") if self.on is not None: self.on = _c.as_bool(self.on) self.offset_m = _c.as_optional_positive(self.offset_m, "offset_m") self.azimuth_deg = _c.as_optional_finite_float( self.azimuth_deg, "azimuth_deg" )
[docs] @classmethod def from_mapping(cls, payload: Mapping[str, Any]) -> SourcePayload: data = dict(payload or {}) known = _known_keys( cls._STATION_ALIASES, cls._SOURCE_ID, cls._CURRENT, cls._VOLTAGE, cls._FREQ, cls._POWER, cls._DIPOLE, cls._DUTY, cls._ON, cls._OFFSET, cls._AZIMUTH, ) return cls( station=cls._consume_station(data), source_id=_first(data, cls._SOURCE_ID), tx_current_a=_first(data, cls._CURRENT), tx_voltage_v=_first(data, cls._VOLTAGE), tx_frequency_hz=_first(data, cls._FREQ), tx_power_w=_first(data, cls._POWER), dipole_length_m=_first(data, cls._DIPOLE), duty_cycle=_first(data, cls._DUTY), on=_first(data, cls._ON), offset_m=_first(data, cls._OFFSET), azimuth_deg=_first(data, cls._AZIMUTH), extra={k: v for k, v in data.items() if k not in known}, )
[docs] def as_dict(self, *, drop_none: bool = False) -> dict[str, Any]: return self._finish( dict( station=self.station, source_id=self.source_id, tx_current_a=self.tx_current_a, tx_voltage_v=self.tx_voltage_v, tx_frequency_hz=self.tx_frequency_hz, tx_power_w=self.tx_power_w, dipole_length_m=self.dipole_length_m, duty_cycle=self.duty_cycle, on=self.on, offset_m=self.offset_m, azimuth_deg=self.azimuth_deg, ), drop_none=drop_none, )
[docs] @dataclass class SyncPayload(TelemetryPayload): """Clock-synchronisation telemetry for a field node.""" kind: PacketKind = PacketKind.SYNC offset_ms: float | None = None drift_ppm: float | None = None jitter_ms: float | None = None gps_lock: bool | None = None n_reference_points: int | None = None reference: str | None = None # Controlled-source (CSAMT/CSEM) transmitter-receiver timing lock. tx_locked: bool | None = None tx_sync_offset_ms: float | None = None tx_id: str | None = None _OFFSET = ("offset_ms", "clock_offset_ms") _DRIFT = ("drift_ppm", "clock_drift_ppm") _JITTER = ("jitter_ms", "clock_jitter_ms") _GPS = ("gps_lock", "gps_locked", "has_gps") _NREF = ("n_reference_points", "n_reference", "n_ref") _REFERENCE = ("reference", "clock_reference", "ref") _TX_LOCKED = ("tx_locked", "transmitter_locked", "source_locked") _TX_OFFSET = ("tx_sync_offset_ms", "tx_offset_ms", "source_offset_ms") _TX_ID = ("tx_id", "transmitter_id", "source_id") def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: self._base_validate() self.offset_ms = _c.as_optional_finite_float( self.offset_ms, "offset_ms" ) self.drift_ppm = _c.as_optional_finite_float( self.drift_ppm, "drift_ppm" ) if self.jitter_ms is not None: self.jitter_ms = _c.as_nonnegative(self.jitter_ms, "jitter_ms") if self.gps_lock is not None: self.gps_lock = _c.as_bool(self.gps_lock) if self.n_reference_points is not None: self.n_reference_points = int(self.n_reference_points) if self.n_reference_points < 0: raise ValueError("n_reference_points must be >= 0.") self.reference = _c.as_optional_str(self.reference, "reference") if self.tx_locked is not None: self.tx_locked = _c.as_bool(self.tx_locked) self.tx_sync_offset_ms = _c.as_optional_finite_float( self.tx_sync_offset_ms, "tx_sync_offset_ms" ) self.tx_id = _c.as_optional_str(self.tx_id, "tx_id")
[docs] @classmethod def from_mapping(cls, payload: Mapping[str, Any]) -> SyncPayload: data = dict(payload or {}) known = _known_keys( cls._STATION_ALIASES, cls._OFFSET, cls._DRIFT, cls._JITTER, cls._GPS, cls._NREF, cls._REFERENCE, cls._TX_LOCKED, cls._TX_OFFSET, cls._TX_ID, ) return cls( station=cls._consume_station(data), offset_ms=_first(data, cls._OFFSET), drift_ppm=_first(data, cls._DRIFT), jitter_ms=_first(data, cls._JITTER), gps_lock=_first(data, cls._GPS), n_reference_points=_first(data, cls._NREF), reference=_first(data, cls._REFERENCE), tx_locked=_first(data, cls._TX_LOCKED), tx_sync_offset_ms=_first(data, cls._TX_OFFSET), tx_id=_first(data, cls._TX_ID), extra={k: v for k, v in data.items() if k not in known}, )
[docs] def as_dict(self, *, drop_none: bool = False) -> dict[str, Any]: return self._finish( dict( station=self.station, offset_ms=self.offset_ms, drift_ppm=self.drift_ppm, jitter_ms=self.jitter_ms, gps_lock=self.gps_lock, n_reference_points=self.n_reference_points, reference=self.reference, tx_locked=self.tx_locked, tx_sync_offset_ms=self.tx_sync_offset_ms, tx_id=self.tx_id, ), drop_none=drop_none, )
[docs] @dataclass class EventPayload(TelemetryPayload): """Discrete field event (state change, alarm, operator note).""" kind: PacketKind = PacketKind.EVENT event: str | None = None severity: EventSeverity | str = EventSeverity.INFO message: str | None = None code: str | None = None _EVENT = ("event", "event_type", "name") _SEVERITY = ("severity", "level") _MESSAGE = ("message", "detail", "description") _CODE = ("code", "event_code") def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: self._base_validate() self.event = _c.as_optional_str(self.event, "event") self.severity = _c.normalise_enum( self.severity, EventSeverity, "severity" ) self.message = _c.as_optional_str(self.message, "message") self.code = _c.as_optional_str(self.code, "code")
[docs] @classmethod def from_mapping(cls, payload: Mapping[str, Any]) -> EventPayload: data = dict(payload or {}) known = _known_keys( cls._STATION_ALIASES, cls._EVENT, cls._SEVERITY, cls._MESSAGE, cls._CODE, ) return cls( station=cls._consume_station(data), event=_first(data, cls._EVENT), severity=_first(data, cls._SEVERITY) or EventSeverity.INFO, message=_first(data, cls._MESSAGE), code=_first(data, cls._CODE), extra={k: v for k, v in data.items() if k not in known}, )
[docs] def as_dict(self, *, drop_none: bool = False) -> dict[str, Any]: severity = ( self.severity.value if isinstance(self.severity, EventSeverity) else str(self.severity) ) return self._finish( dict( station=self.station, event=self.event, severity=severity, message=self.message, code=self.code, ), drop_none=drop_none, )
[docs] @dataclass class AcquisitionPayload(TelemetryPayload): """Metadata describing one raw acquisition record/window.""" kind: PacketKind = PacketKind.DATA method: str | None = None channels: list[str] = field(default_factory=list) sample_rate_hz: float | None = None frequency_hz: float | None = None frequency_band_hz: tuple[float, float] | None = None n_samples: int | None = None gain: float | None = None duration_s: float | None = None _METHOD = ("method", "survey_method", "em_method") _CHANNELS = ("channels", "channel") _RATE = ("sample_rate_hz", "sample_rate", "fs_hz", "fs") _FREQ = ("frequency_hz", "freq_hz", "frequency") _BAND = ("frequency_band_hz", "band_hz", "freq_band_hz") _NSAMPLES = ("n_samples", "nsamples", "n_sample") _GAIN = ("gain", "gain_db") _DURATION = ("duration_s", "duration", "window_s") def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: self._base_validate() self.method = _c.as_optional_str(self.method, "method") if self.method is not None: self.method = self.method.lower() self.channels = _c.as_channel_list(self.channels) self.sample_rate_hz = _c.as_optional_positive( self.sample_rate_hz, "sample_rate_hz" ) self.frequency_hz = _c.as_optional_positive( self.frequency_hz, "frequency_hz" ) self.frequency_band_hz = _normalise_band(self.frequency_band_hz) if self.n_samples is not None: self.n_samples = int(self.n_samples) if self.n_samples < 0: raise ValueError("n_samples must be >= 0.") self.gain = _c.as_optional_finite_float(self.gain, "gain") self.duration_s = _c.as_optional_positive( self.duration_s, "duration_s" )
[docs] @classmethod def from_mapping(cls, payload: Mapping[str, Any]) -> AcquisitionPayload: data = dict(payload or {}) known = _known_keys( cls._STATION_ALIASES, cls._METHOD, cls._CHANNELS, cls._RATE, cls._FREQ, cls._BAND, cls._NSAMPLES, cls._GAIN, cls._DURATION, ) return cls( station=cls._consume_station(data), method=_first(data, cls._METHOD), channels=_first(data, cls._CHANNELS) or [], sample_rate_hz=_first(data, cls._RATE), frequency_hz=_first(data, cls._FREQ), frequency_band_hz=_first(data, cls._BAND), n_samples=_first(data, cls._NSAMPLES), gain=_first(data, cls._GAIN), duration_s=_first(data, cls._DURATION), extra={k: v for k, v in data.items() if k not in known}, )
[docs] def as_dict(self, *, drop_none: bool = False) -> dict[str, Any]: return self._finish( dict( station=self.station, method=self.method, channels=list(self.channels), sample_rate_hz=self.sample_rate_hz, frequency_hz=self.frequency_hz, frequency_band_hz=( list(self.frequency_band_hz) if self.frequency_band_hz is not None else None ), n_samples=self.n_samples, gain=self.gain, duration_s=self.duration_s, ), drop_none=drop_none, )
def _normalise_band(value: Any) -> tuple[float, float] | None: if value is None: return None try: lo, hi = list(value)[:2] except Exception as exc: # noqa: BLE001 - report a clear message raise ValueError( "frequency_band_hz must be a (low, high) pair." ) from exc lo = _c.as_positive(lo, "frequency_band_hz[0]") hi = _c.as_positive(hi, "frequency_band_hz[1]") if lo > hi: raise ValueError("frequency_band_hz must be ordered (low <= high).") return (lo, hi) def _as_str_list(value: Any) -> list[str]: if value is None: return [] if isinstance(value, str): # Support ";"-joined reason strings emitted by edge tables. parts = [p.strip() for p in value.split(";")] return [p for p in parts if p] return [str(v) for v in list(value)] #: Map each packet kind to its canonical payload schema. PAYLOAD_SCHEMAS: dict[PacketKind, type[TelemetryPayload]] = { PacketKind.HEALTH: HealthPayload, PacketKind.QC: QCPayload, PacketKind.POWER: PowerPayload, PacketKind.SYNC: SyncPayload, PacketKind.EVENT: EventPayload, PacketKind.DATA: AcquisitionPayload, PacketKind.SOURCE: SourcePayload, }
[docs] def schema_for(kind: PacketKind | str) -> type[TelemetryPayload]: """Return the payload schema registered for *kind*.""" pkt_kind = _c.normalise_enum(kind, PacketKind, "kind") try: return PAYLOAD_SCHEMAS[pkt_kind] except KeyError as exc: # pragma: no cover - all kinds are mapped raise ValueError(f"No payload schema for kind {pkt_kind!r}.") from exc
[docs] def parse_payload( kind: PacketKind | str, payload: Mapping[str, Any], ) -> TelemetryPayload: """Parse *payload* into the schema registered for *kind*.""" return schema_for(kind).from_mapping(payload)
[docs] def validate_payload( kind: PacketKind | str, payload: Mapping[str, Any], *, drop_none: bool = True, ) -> dict[str, Any]: """Return a canonicalised payload dictionary for *kind*. Aliased keys are folded into canonical names and values are range checked. Unknown keys are preserved. Raises :class:`ValueError` when a field fails validation. """ return parse_payload(kind, payload).as_dict(drop_none=drop_none)