init commit

This commit is contained in:
Ayzen
2026-03-05 14:42:33 +03:00
commit fd4618b20d
964 changed files with 325114 additions and 0 deletions
@@ -0,0 +1,31 @@
"""Protocol framing and payload codecs for LibreVNA packet protocol v14."""
from .codec import (
decode_packet_payload,
decode_vna_datapoint_payload,
encode_device_config_payload,
encode_packet_payload,
encode_sweep_settings_payload,
ensure_no_payload_types,
NO_PAYLOAD_PACKET_TYPES,
parse_device_config,
parse_device_info,
parse_device_status,
)
from .frame import FrameScanner, decode_frame, encode_frame
__all__ = [
"FrameScanner",
"NO_PAYLOAD_PACKET_TYPES",
"decode_frame",
"decode_packet_payload",
"decode_vna_datapoint_payload",
"encode_device_config_payload",
"encode_frame",
"encode_packet_payload",
"encode_sweep_settings_payload",
"ensure_no_payload_types",
"parse_device_config",
"parse_device_info",
"parse_device_status",
]
@@ -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")
@@ -0,0 +1,10 @@
"""CRC32 helpers for LibreVNA packet framing."""
from __future__ import annotations
import zlib
def crc32(data: bytes | bytearray | memoryview) -> int:
"""Compute protocol CRC32 over packet bytes excluding trailing CRC field."""
return zlib.crc32(data) & 0xFFFFFFFF
@@ -0,0 +1,123 @@
"""Packet frame encoding/decoding for LibreVNA protocol stream."""
from __future__ import annotations
import logging
import struct
from ..enums import PacketType
from ..exceptions import CRCError, ParseError
from ..models import Packet
from .crc32 import crc32
logger = logging.getLogger(__name__)
_HEADER = 0x5A
_FRAME_OVERHEAD = 8 # header + length + type + crc
_MAX_FRAME_LENGTH = 4096
_NO_CRC_PACKET_TYPES = {
PacketType.VNA_DATAPOINT,
}
def encode_frame(packet: Packet) -> bytes:
"""Encode one packet into framed wire format."""
payload = packet.payload
if not isinstance(payload, (bytes, bytearray, memoryview)):
raise TypeError("Packet payload must be bytes-like")
payload_bytes = bytes(payload)
length = _FRAME_OVERHEAD + len(payload_bytes)
if length > 0xFFFF:
raise ValueError("Packet is too large for protocol frame length field")
frame = bytearray(length)
frame[0] = _HEADER
struct.pack_into("<H", frame, 1, length)
frame[3] = int(packet.type)
frame[4 : 4 + len(payload_bytes)] = payload_bytes
crc_value = 0
if packet.type not in _NO_CRC_PACKET_TYPES:
crc_value = crc32(frame[:-4])
struct.pack_into("<I", frame, length - 4, crc_value)
return bytes(frame)
def decode_frame(frame: bytes) -> Packet:
"""Decode and validate one complete frame."""
if len(frame) < _FRAME_OVERHEAD:
raise ParseError("Frame is too short")
if frame[0] != _HEADER:
raise ParseError("Invalid frame header")
(length,) = struct.unpack_from("<H", frame, 1)
if length != len(frame):
raise ParseError(f"Frame length mismatch: declared {length}, got {len(frame)}")
packet_type_raw = frame[3]
try:
packet_type = PacketType(packet_type_raw)
except ValueError as exc:
raise ParseError(f"Unknown packet type id {packet_type_raw}") from exc
(received_crc,) = struct.unpack_from("<I", frame, length - 4)
if packet_type in _NO_CRC_PACKET_TYPES:
if received_crc != 0:
raise CRCError("VNADatapoint packet must carry zero CRC")
else:
computed_crc = crc32(frame[:-4])
if received_crc != computed_crc:
raise CRCError(
f"CRC mismatch for packet {packet_type.name}: "
f"received 0x{received_crc:08X}, computed 0x{computed_crc:08X}"
)
return Packet(type=packet_type, payload=frame[4:-4])
class FrameScanner:
"""Incremental frame scanner for raw USB byte streams."""
def __init__(self) -> None:
"""Initialize internal undecoded byte buffer."""
self._buffer = bytearray()
def clear(self) -> None:
"""Drop all buffered undecoded bytes."""
self._buffer.clear()
def feed(self, chunk: bytes) -> list[Packet]:
"""Feed raw bytes and return every fully decoded packet."""
if not chunk:
return []
self._buffer.extend(chunk)
decoded: list[Packet] = []
while True:
header_index = self._buffer.find(_HEADER)
if header_index < 0:
self._buffer.clear()
break
if header_index > 0:
del self._buffer[:header_index]
if len(self._buffer) < 4:
break
(length,) = struct.unpack_from("<H", self._buffer, 1)
if length < _FRAME_OVERHEAD or length > _MAX_FRAME_LENGTH:
logger.debug("Discarding byte due to invalid frame length=%d", length)
del self._buffer[0]
continue
if len(self._buffer) < length:
break
frame = bytes(self._buffer[:length])
del self._buffer[:length]
decoded.append(decode_frame(frame))
return decoded
@@ -0,0 +1,89 @@
"""ctypes layouts for protocol status unions used by the driver."""
from __future__ import annotations
import ctypes
class DeviceStatusV1(ctypes.LittleEndianStructure):
"""Device status bit layout for V1 family."""
_pack_ = 1
_fields_ = [
("extRefAvailable", ctypes.c_uint8, 1),
("extRefInUse", ctypes.c_uint8, 1),
("FPGA_configured", ctypes.c_uint8, 1),
("source_locked", ctypes.c_uint8, 1),
("LO1_locked", ctypes.c_uint8, 1),
("ADC_overload", ctypes.c_uint8, 1),
("unlevel", ctypes.c_uint8, 1),
("_unused", ctypes.c_uint8, 1),
("temp_source", ctypes.c_uint8),
("temp_LO1", ctypes.c_uint8),
("temp_MCU", ctypes.c_uint8),
]
class DeviceStatusVFF(ctypes.LittleEndianStructure):
"""Device status bit layout for VFF family."""
_pack_ = 1
_fields_ = [
("source_locked", ctypes.c_uint8, 1),
("LO_locked", ctypes.c_uint8, 1),
("ADC_overload", ctypes.c_uint8, 1),
("unlevel", ctypes.c_uint8, 1),
("_unused", ctypes.c_uint8, 4),
("temp_MCU", ctypes.c_uint8),
]
class DeviceStatusVFE(ctypes.LittleEndianStructure):
"""Device status bit layout for VFE family."""
_pack_ = 1
_fields_ = [
("source_locked", ctypes.c_uint8, 1),
("LO_locked", ctypes.c_uint8, 1),
("ADC_overload", ctypes.c_uint8, 1),
("unlevel", ctypes.c_uint8, 1),
("_unused", ctypes.c_uint8, 4),
("temp_MCU", ctypes.c_uint8),
("temp_eCal", ctypes.c_uint16),
("power_heater", ctypes.c_uint16),
]
class DeviceStatusVD0(ctypes.LittleEndianStructure):
"""Device status bit layout for VD0/VE0 families."""
_pack_ = 1
_fields_ = [
("extRefAvailable", ctypes.c_uint8, 1),
("extRefInUse", ctypes.c_uint8, 1),
("FPGA_configured", ctypes.c_uint8, 1),
("source_locked", ctypes.c_uint8, 1),
("LO_locked", ctypes.c_uint8, 1),
("ADC_overload", ctypes.c_uint8, 1),
("unlevel", ctypes.c_uint8, 1),
("_unused", ctypes.c_uint8, 1),
("temp_MCU", ctypes.c_uint8),
("supply_voltage", ctypes.c_uint16),
("supply_current", ctypes.c_uint16),
]
class DeviceStatusUnion(ctypes.Union):
"""6-byte device-status union shared across hardware families."""
_pack_ = 1
_fields_ = [
("V1", DeviceStatusV1),
("VFF", DeviceStatusVFF),
("VFE", DeviceStatusVFE),
("VD0", DeviceStatusVD0),
("raw", ctypes.c_uint8 * 6),
]
assert ctypes.sizeof(DeviceStatusUnion) == 6