init commit
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
"""Payload encoders/decoders for LibreVNA protocol packets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..enums import HardwareFamily, PacketType
|
||||
from ..exceptions import ParseError, UnsupportedHardwareError
|
||||
from ..models import (
|
||||
DeviceConfigVariant,
|
||||
DeviceInfo,
|
||||
DeviceLimits,
|
||||
DeviceStatus,
|
||||
Packet,
|
||||
VNADatapointPacket,
|
||||
VNASweepSettings,
|
||||
)
|
||||
from .structs import DeviceStatusUnion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEVICE_INFO_STRUCT = struct.Struct("<HBBBBcQQIIHhhIIBQBH")
|
||||
_SWEEP_SETTINGS_STRUCT = struct.Struct("<QQHIhBHhH")
|
||||
_DEVICE_CONFIG_V1_STRUCT = struct.Struct("<IBHB")
|
||||
_DEVICE_CONFIG_VFF_STRUCT = struct.Struct("<IIIBH")
|
||||
_DEVICE_CONFIG_VFE_STRUCT = struct.Struct("<H")
|
||||
_DEVICE_CONFIG_VD0_STRUCT = struct.Struct("<HIB")
|
||||
|
||||
_DEVICE_STATUS_VARIANT_BY_FAMILY: dict[HardwareFamily, str] = {
|
||||
HardwareFamily.V1: "V1",
|
||||
HardwareFamily.VFF: "VFF",
|
||||
HardwareFamily.VFE: "VFE",
|
||||
HardwareFamily.VD0: "VD0",
|
||||
HardwareFamily.VE0: "VD0",
|
||||
}
|
||||
|
||||
|
||||
def _family_from_hardware_version(hardware_version: int) -> HardwareFamily:
|
||||
"""Map hardware version byte to typed hardware family enum."""
|
||||
try:
|
||||
return HardwareFamily(hardware_version)
|
||||
except ValueError as exc:
|
||||
raise UnsupportedHardwareError(
|
||||
f"Unsupported hardware_version in DeviceInfo: 0x{hardware_version:02X}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _ensure_payload_length(payload: bytes, expected: int, *, packet_name: str) -> None:
|
||||
"""Validate exact payload length for fixed-size packet structures."""
|
||||
if len(payload) != expected:
|
||||
raise ParseError(
|
||||
f"{packet_name} payload length mismatch: expected {expected}, got {len(payload)}"
|
||||
)
|
||||
|
||||
|
||||
def parse_device_info(payload: bytes) -> DeviceInfo:
|
||||
"""Parse `DeviceInfo` packet payload into typed model."""
|
||||
_ensure_payload_length(payload, _DEVICE_INFO_STRUCT.size, packet_name="DeviceInfo")
|
||||
|
||||
(
|
||||
protocol_version,
|
||||
fw_major,
|
||||
fw_minor,
|
||||
fw_patch,
|
||||
hardware_version,
|
||||
hw_revision_raw,
|
||||
min_freq,
|
||||
max_freq,
|
||||
min_ifbw,
|
||||
max_ifbw,
|
||||
max_points,
|
||||
min_cdbm,
|
||||
max_cdbm,
|
||||
min_rbw,
|
||||
max_rbw,
|
||||
max_amplitude_points,
|
||||
max_harmonic,
|
||||
num_ports,
|
||||
max_dwell_time_us,
|
||||
) = _DEVICE_INFO_STRUCT.unpack(payload)
|
||||
|
||||
family = _family_from_hardware_version(hardware_version)
|
||||
hw_revision = hw_revision_raw.decode("ascii", errors="replace")
|
||||
|
||||
limits = DeviceLimits(
|
||||
min_frequency_hz=float(min_freq),
|
||||
max_frequency_hz=float(max_freq),
|
||||
max_frequency_harmonic_hz=float(max_harmonic),
|
||||
min_ifbw_hz=float(min_ifbw),
|
||||
max_ifbw_hz=float(max_ifbw),
|
||||
max_points=int(max_points),
|
||||
min_power_dbm=float(min_cdbm) / 100.0,
|
||||
max_power_dbm=float(max_cdbm) / 100.0,
|
||||
min_rbw_hz=float(min_rbw),
|
||||
max_rbw_hz=float(max_rbw),
|
||||
max_amplitude_points=int(max_amplitude_points),
|
||||
max_dwell_time_s=float(max_dwell_time_us) * 1e-6,
|
||||
)
|
||||
|
||||
device_info = DeviceInfo(
|
||||
protocol_version=int(protocol_version),
|
||||
firmware_major=int(fw_major),
|
||||
firmware_minor=int(fw_minor),
|
||||
firmware_patch=int(fw_patch),
|
||||
firmware_version=f"{fw_major}.{fw_minor}.{fw_patch}",
|
||||
hardware_version=int(hardware_version),
|
||||
hardware_revision=hw_revision,
|
||||
hardware_family=family,
|
||||
limits=limits,
|
||||
num_ports=int(num_ports),
|
||||
)
|
||||
logger.debug(
|
||||
"Decoded DeviceInfo: protocol=%d fw=%s family=%s ports=%d",
|
||||
device_info.protocol_version,
|
||||
device_info.firmware_version,
|
||||
device_info.hardware_family.name,
|
||||
device_info.num_ports,
|
||||
)
|
||||
return device_info
|
||||
|
||||
|
||||
def _device_status_variant_name_for_family(family: HardwareFamily) -> str:
|
||||
"""Resolve `DeviceStatusUnion` variant name for specific hardware family."""
|
||||
try:
|
||||
return _DEVICE_STATUS_VARIANT_BY_FAMILY[family]
|
||||
except KeyError as exc:
|
||||
raise UnsupportedHardwareError(
|
||||
f"Unsupported hardware family for DeviceStatus: {family!r}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _structure_fields_dict(struct_obj: ctypes.Structure) -> dict[str, int | float | bool]:
|
||||
"""Convert ctypes structure fields into plain Python dictionary."""
|
||||
values: dict[str, int | float | bool] = {}
|
||||
for entry in struct_obj._fields_:
|
||||
field_name = entry[0]
|
||||
if field_name.startswith("_"):
|
||||
continue
|
||||
raw_value = getattr(struct_obj, field_name)
|
||||
|
||||
if len(entry) == 3:
|
||||
values[field_name] = bool(raw_value)
|
||||
continue
|
||||
|
||||
field_type = entry[1]
|
||||
if field_type in {ctypes.c_float, ctypes.c_double}:
|
||||
values[field_name] = float(raw_value)
|
||||
else:
|
||||
values[field_name] = int(raw_value)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def parse_device_status(payload: bytes, family: HardwareFamily) -> DeviceStatus:
|
||||
"""Parse `DeviceStatus` payload according to active hardware family."""
|
||||
_ensure_payload_length(payload, ctypes.sizeof(DeviceStatusUnion), packet_name="DeviceStatus")
|
||||
|
||||
union = DeviceStatusUnion()
|
||||
ctypes.memmove(ctypes.addressof(union), payload, len(payload))
|
||||
variant_name = _device_status_variant_name_for_family(family)
|
||||
variant = getattr(union, variant_name)
|
||||
raw = _structure_fields_dict(variant)
|
||||
|
||||
source_locked = bool(raw.get("source_locked")) if "source_locked" in raw else None
|
||||
lo_locked = None
|
||||
if "LO_locked" in raw:
|
||||
lo_locked = bool(raw["LO_locked"])
|
||||
elif "LO1_locked" in raw:
|
||||
lo_locked = bool(raw["LO1_locked"])
|
||||
|
||||
adc_overload = bool(raw.get("ADC_overload")) if "ADC_overload" in raw else None
|
||||
unlevel = bool(raw.get("unlevel")) if "unlevel" in raw else None
|
||||
|
||||
temperatures_c: list[float] = []
|
||||
for key in ("temp_source", "temp_LO1", "temp_MCU"):
|
||||
if key in raw:
|
||||
temperatures_c.append(float(raw[key]))
|
||||
if "temp_eCal" in raw:
|
||||
temperatures_c.append(float(raw["temp_eCal"]) / 100.0)
|
||||
|
||||
return DeviceStatus(
|
||||
family=family,
|
||||
source_locked=source_locked,
|
||||
lo_locked=lo_locked,
|
||||
adc_overload=adc_overload,
|
||||
unlevel=unlevel,
|
||||
temperatures_c=temperatures_c,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
|
||||
def decode_vna_datapoint_payload(payload: bytes) -> VNADatapointPacket:
|
||||
"""Decode variable-size VNADatapoint payload."""
|
||||
if len(payload) < 12:
|
||||
raise ParseError("VNADatapoint payload is too short")
|
||||
|
||||
values_block = len(payload) - 12
|
||||
if values_block % 9 != 0:
|
||||
raise ParseError("VNADatapoint payload length is not aligned to value tuple size")
|
||||
|
||||
num_values = values_block // 9
|
||||
|
||||
(frequency_or_time,) = struct.unpack_from("<Q", payload, 0)
|
||||
(cdbm,) = struct.unpack_from("<h", payload, 8)
|
||||
(point_number,) = struct.unpack_from("<H", payload, 10)
|
||||
|
||||
real = np.frombuffer(payload, dtype="<f4", count=num_values, offset=12).astype(np.float64, copy=False)
|
||||
imag = np.frombuffer(payload, dtype="<f4", count=num_values, offset=12 + 4 * num_values).astype(
|
||||
np.float64,
|
||||
copy=False,
|
||||
)
|
||||
flags = np.frombuffer(payload, dtype=np.uint8, count=num_values, offset=12 + 8 * num_values)
|
||||
|
||||
return VNADatapointPacket(
|
||||
frequency_or_time=int(frequency_or_time),
|
||||
cdbm=int(cdbm),
|
||||
point_number=int(point_number),
|
||||
real=real,
|
||||
imag=imag,
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
|
||||
def decode_packet_payload(packet: Packet) -> object:
|
||||
"""Decode packet payload for known response packet types."""
|
||||
if packet.type == PacketType.DEVICE_INFO:
|
||||
return parse_device_info(packet.payload)
|
||||
if packet.type == PacketType.VNA_DATAPOINT:
|
||||
return decode_vna_datapoint_payload(packet.payload)
|
||||
return packet.payload
|
||||
|
||||
|
||||
def encode_sweep_settings_payload(settings: VNASweepSettings) -> bytes:
|
||||
"""Encode `SweepSettings` payload."""
|
||||
if len(settings.excited_ports) > 4:
|
||||
raise ValueError("Protocol supports at most four excited ports")
|
||||
sync_mode = settings.sync_mode if settings.sync_mode is not None else 0
|
||||
|
||||
stage_by_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
|
||||
|
||||
flags1 = 0
|
||||
flags1 |= int(bool(settings.standby)) << 0
|
||||
flags1 |= int(bool(settings.sync_master)) << 1
|
||||
flags1 |= int(bool(settings.suppress_invalid_peaks)) << 2
|
||||
flags1 |= int(bool(settings.fixed_power_setting)) << 3
|
||||
flags1 |= int(settings.sweep_scale.value == "log") << 4
|
||||
flags1 |= (int(sync_mode) & 0x03) << 5
|
||||
|
||||
flags2 = 0
|
||||
stages = len(settings.excited_ports) - 1
|
||||
flags2 |= stages & 0x07
|
||||
flags2 |= (stage_by_port.get(1, 0) & 0x07) << 3
|
||||
flags2 |= (stage_by_port.get(2, 0) & 0x07) << 6
|
||||
flags2 |= (stage_by_port.get(3, 0) & 0x07) << 9
|
||||
flags2 |= (stage_by_port.get(4, 0) & 0x07) << 12
|
||||
|
||||
dwell_us = int(round(settings.dwell_s * 1_000_000.0))
|
||||
dwell_us = max(0, min(0xFFFF, dwell_us))
|
||||
|
||||
return _SWEEP_SETTINGS_STRUCT.pack(
|
||||
int(round(settings.f_start_hz)),
|
||||
int(round(settings.f_stop_hz)),
|
||||
int(settings.points),
|
||||
int(round(settings.if_bandwidth_hz)),
|
||||
int(round(settings.power_start_dbm * 100.0)),
|
||||
flags1,
|
||||
flags2,
|
||||
int(round(settings.power_stop_dbm * 100.0)),
|
||||
dwell_us,
|
||||
)
|
||||
|
||||
|
||||
def parse_device_config(payload: bytes, family: HardwareFamily) -> DeviceConfigVariant:
|
||||
"""Decode family-specific `DeviceConfiguration` payload."""
|
||||
if len(payload) != 15:
|
||||
raise ParseError(f"DeviceConfiguration payload length mismatch: expected 15, got {len(payload)}")
|
||||
|
||||
values: dict[str, int | float | bool]
|
||||
if family == HardwareFamily.V1:
|
||||
if1, adc_prescaler, dft_phase_inc, pll_delay = _DEVICE_CONFIG_V1_STRUCT.unpack(payload[:8])
|
||||
values = {
|
||||
"IF1": int(if1),
|
||||
"ADCprescaler": int(adc_prescaler),
|
||||
"DFTphaseInc": int(dft_phase_inc),
|
||||
"PLLSettlingDelay": int(pll_delay),
|
||||
}
|
||||
elif family == HardwareFamily.VFF:
|
||||
ip, mask, gw, flags1, flags2 = _DEVICE_CONFIG_VFF_STRUCT.unpack(payload)
|
||||
values = {
|
||||
"ip": int(ip),
|
||||
"mask": int(mask),
|
||||
"gw": int(gw),
|
||||
"dhcp": bool(flags1 & 0x01),
|
||||
"autogain": bool(flags2 & 0x01),
|
||||
"portGain": int((flags2 >> 1) & 0x0F),
|
||||
"refGain": int((flags2 >> 5) & 0x0F),
|
||||
}
|
||||
elif family == HardwareFamily.VFE:
|
||||
(flags,) = _DEVICE_CONFIG_VFE_STRUCT.unpack(payload[:2])
|
||||
values = {
|
||||
"autogain": bool(flags & 0x01),
|
||||
"portGain": int((flags >> 1) & 0x0F),
|
||||
"refGain": int((flags >> 5) & 0x0F),
|
||||
}
|
||||
elif family in {HardwareFamily.VD0, HardwareFamily.VE0}:
|
||||
dft_phase_inc, adc_rate, pll_delay = _DEVICE_CONFIG_VD0_STRUCT.unpack(payload[:7])
|
||||
values = {
|
||||
"DFTphaseInc": int(dft_phase_inc),
|
||||
"ADCrate": int(adc_rate),
|
||||
"PLLSettlingDelay": int(pll_delay),
|
||||
}
|
||||
else:
|
||||
raise UnsupportedHardwareError(f"Unsupported hardware family for DeviceConfiguration: {family!r}")
|
||||
|
||||
return DeviceConfigVariant(family=family, values=values)
|
||||
|
||||
|
||||
def encode_device_config_payload(config: DeviceConfigVariant) -> bytes:
|
||||
"""Encode family-specific `DeviceConfiguration` payload."""
|
||||
family = config.family
|
||||
values = config.values
|
||||
|
||||
def _require_value(key: str) -> int | float | bool:
|
||||
"""Fetch required device-config value by key or raise parse error."""
|
||||
if key not in values:
|
||||
raise ParseError(f"Missing required device configuration field: '{key}'")
|
||||
return values[key]
|
||||
|
||||
if family == HardwareFamily.V1:
|
||||
payload = _DEVICE_CONFIG_V1_STRUCT.pack(
|
||||
int(_require_value("IF1")),
|
||||
int(_require_value("ADCprescaler")),
|
||||
int(_require_value("DFTphaseInc")),
|
||||
int(_require_value("PLLSettlingDelay")),
|
||||
)
|
||||
elif family == HardwareFamily.VFF:
|
||||
flags1 = int(bool(_require_value("dhcp")))
|
||||
flags2 = 0
|
||||
flags2 |= int(bool(_require_value("autogain"))) << 0
|
||||
flags2 |= (int(_require_value("portGain")) & 0x0F) << 1
|
||||
flags2 |= (int(_require_value("refGain")) & 0x0F) << 5
|
||||
payload = _DEVICE_CONFIG_VFF_STRUCT.pack(
|
||||
int(_require_value("ip")),
|
||||
int(_require_value("mask")),
|
||||
int(_require_value("gw")),
|
||||
flags1,
|
||||
flags2,
|
||||
)
|
||||
elif family == HardwareFamily.VFE:
|
||||
flags = 0
|
||||
flags |= int(bool(_require_value("autogain"))) << 0
|
||||
flags |= (int(_require_value("portGain")) & 0x0F) << 1
|
||||
flags |= (int(_require_value("refGain")) & 0x0F) << 5
|
||||
payload = _DEVICE_CONFIG_VFE_STRUCT.pack(flags)
|
||||
elif family in {HardwareFamily.VD0, HardwareFamily.VE0}:
|
||||
payload = _DEVICE_CONFIG_VD0_STRUCT.pack(
|
||||
int(_require_value("DFTphaseInc")),
|
||||
int(_require_value("ADCrate")),
|
||||
int(_require_value("PLLSettlingDelay")),
|
||||
)
|
||||
else:
|
||||
raise UnsupportedHardwareError(f"Unsupported hardware family for DeviceConfiguration: {family!r}")
|
||||
|
||||
return payload.ljust(15, b"\x00")
|
||||
|
||||
|
||||
def encode_packet_payload(packet_type: PacketType, payload: object) -> bytes:
|
||||
"""Generic payload encoder for `LibreVNASession.send()` raw API."""
|
||||
if payload is None:
|
||||
return b""
|
||||
if isinstance(payload, (bytes, bytearray, memoryview)):
|
||||
return bytes(payload)
|
||||
|
||||
if packet_type == PacketType.SWEEP_SETTINGS and isinstance(payload, VNASweepSettings):
|
||||
return encode_sweep_settings_payload(payload)
|
||||
if packet_type == PacketType.DEVICE_CONFIGURATION and isinstance(payload, DeviceConfigVariant):
|
||||
return encode_device_config_payload(payload)
|
||||
|
||||
logger.error(
|
||||
"Unsupported payload object for packet %s: %s",
|
||||
packet_type.name,
|
||||
type(payload).__name__,
|
||||
)
|
||||
raise TypeError(f"Unsupported payload object for packet {packet_type.name}: {type(payload).__name__}")
|
||||
|
||||
|
||||
NO_PAYLOAD_PACKET_TYPES = {
|
||||
PacketType.ACK,
|
||||
PacketType.NACK,
|
||||
PacketType.REQUEST_DEVICE_INFO,
|
||||
PacketType.REQUEST_DEVICE_CONFIGURATION,
|
||||
PacketType.REQUEST_DEVICE_STATUS,
|
||||
PacketType.INITIATE_SWEEP,
|
||||
PacketType.RESET_DEVICE_CONFIGURATION,
|
||||
}
|
||||
|
||||
|
||||
def ensure_no_payload_types(packet_type: PacketType, payload: bytes) -> None:
|
||||
"""Validate empty payload requirement for no-payload packet types."""
|
||||
if packet_type in NO_PAYLOAD_PACKET_TYPES and payload:
|
||||
logger.error("No-payload packet %s received payload length %d", packet_type.name, len(payload))
|
||||
raise ParseError(f"Packet {packet_type.name} does not support payload")
|
||||
Reference in New Issue
Block a user