From 2a70a4d5f8281f35a4c0389a679d6565380097cb Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 03:45:26 +0000 Subject: [PATCH 01/17] refactor: separate bitfield type into bitfield.py --- opendis/bitfield.py | 100 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 opendis/bitfield.py diff --git a/opendis/bitfield.py b/opendis/bitfield.py new file mode 100644 index 0000000..e0bbb8d --- /dev/null +++ b/opendis/bitfield.py @@ -0,0 +1,100 @@ +"""Bitfield type factory and related utilities. + +This module provides a factory function to create ctypes.Structure subclasses +representing bitfields as defined in the DIS standard. These bitfields are used +in various DIS records to pack multiple non-octet-aligned fields into a compact +binary representation. +""" + +from ctypes import ( + _SimpleCData, + BigEndianStructure, + c_uint8, + c_uint16, + c_uint32, + sizeof, +) +from typing import Literal, Sequence + +from opendis.stream import DataInputStream, DataOutputStream + +# Type definitions for bitfield field descriptors +CTypeFieldDescription = tuple[str, type[_SimpleCData], int] +DisFieldDescription = tuple[str, "DisFieldType", int] + +# Field type constants simplify the construction of bitfields +# which would otherwise require manually specifying ctypes types. +# The currently implemented bitfields only use integers, but DIS7 +# mentions CHAR types which may be needed in future. +DisFieldType = Literal["INTEGER"] +INTEGER = "INTEGER" + + +def _field(name: str, + ftype: DisFieldType, + bits: int) -> CTypeFieldDescription: + """Helper function to create the field description tuple used by ctypes.""" + match (ftype, bits): + case (INTEGER, b) if 0 < b <= 8: + return (name, c_uint8, bits) + case (INTEGER, b) if 8 < b <= 16: + return (name, c_uint16, bits) + case (INTEGER, b) if 16 < b <= 32: + return (name, c_uint32, bits) + case _: + raise ValueError(f"Unrecognized (ftype, bits): {ftype}, {bits}") + + +def bitfield(name: str, + fields: Sequence[DisFieldDescription]): + """Factory function for bitfield structs, which are subclasses of + ctypes.Structure. + These are used in records that require them to unpack non-octet-sized fields. + + Args: + name: Name of the bitfield struct. + bytesize: Size of the bitfield in bytes. + fields: Sequence of tuples defining fields of the bitfield, in the form + (field_name, "INTEGER", field_size_in_bits). + """ + # Argument validation + struct_fields = [] + bitsize = 0 + for name, ftype, bits in fields: + if ftype not in (INTEGER,): + raise ValueError(f"Unsupported field type: {ftype}") + if not isinstance(bits, int): + raise ValueError(f"Field size must be int: {bits!r}") + if bits <= 0 or bits > 32: + raise ValueError(f"Field size must be between 1 and 32: got {bits}") + bitsize += bits + struct_fields.append(_field(name, ftype, bits)) + + if bitsize == 0: + raise ValueError(f"Bitfield size cannot be zero") + elif bitsize % 8 != 0: + raise ValueError(f"Bitfield size must be multiple of 8, got {bitsize}") + bytesize = bitsize // 8 + + # Create the struct class + class Bitfield(BigEndianStructure): + _fields_ = struct_fields + + @staticmethod + def marshalledSize() -> int: + return bytesize + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_bytes(bytes(self)) + + @classmethod + def parse(cls, inputStream: DataInputStream) -> "Bitfield": + return cls.from_buffer_copy(inputStream.read_bytes(bytesize)) + + # Sanity check: ensure the struct size matches expected size + assert sizeof(Bitfield) == bytesize, \ + f"Bitfield size mismatch: expected {bytesize}, got {sizeof(Bitfield)}" + + # Assign the class name + Bitfield.__name__ = name + return Bitfield From 33517f28b15d2ec50186f75a3987a8e51ce68803 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 03:51:12 +0000 Subject: [PATCH 02/17] refactor: separate bitfield type into bitfield.py --- opendis/record.py | 112 +++++----------------------------------------- 1 file changed, 11 insertions(+), 101 deletions(-) diff --git a/opendis/record.py b/opendis/record.py index 4631b2b..2bd0fb9 100644 --- a/opendis/record.py +++ b/opendis/record.py @@ -4,17 +4,8 @@ """ from abc import abstractmethod -from collections.abc import Sequence -from ctypes import ( - _SimpleCData, - BigEndianStructure, - c_uint8, - c_uint16, - c_uint32, - sizeof, -) -from typing import Literal +import bitfield from .stream import DataInputStream, DataOutputStream from .types import ( enum16, @@ -26,87 +17,6 @@ uint32, ) -# Type definitions for bitfield field descriptors -CTypeFieldDescription = tuple[str, type[_SimpleCData], int] -DisFieldDescription = tuple[str, "DisFieldType", int] - -# Field type constants simplify the construction of bitfields -# which would otherwise require manually specifying ctypes types. -# The currently implemented bitfields only use integers, but DIS7 -# mentions CHAR types which may be needed in future. -DisFieldType = Literal["INTEGER"] -INTEGER = "INTEGER" - - -def field(name: str, - ftype: DisFieldType, - bits: int) -> CTypeFieldDescription: - """Helper function to create the field description tuple used by ctypes.""" - match (ftype, bits): - case (INTEGER, b) if 0 < b <= 8: - return (name, c_uint8, bits) - case (INTEGER, b) if 8 < b <= 16: - return (name, c_uint16, bits) - case (INTEGER, b) if 16 < b <= 32: - return (name, c_uint32, bits) - case _: - raise ValueError(f"Unrecognized (ftype, bits): {ftype}, {bits}") - - -def _bitfield(name: str, - fields: Sequence[DisFieldDescription]): - """Factory function for bitfield structs, which are subclasses of - ctypes.Structure. - These are used in records that require them to unpack non-octet-sized fields. - - Args: - name: Name of the bitfield struct. - bytesize: Size of the bitfield in bytes. - fields: Sequence of tuples defining fields of the bitfield, in the form - (field_name, "INTEGER", field_size_in_bits). - """ - # Argument validation - struct_fields = [] - bitsize = 0 - for name, ftype, bits in fields: - if ftype not in (INTEGER,): - raise ValueError(f"Unsupported field type: {ftype}") - if not isinstance(bits, int): - raise ValueError(f"Field size must be int: {bits!r}") - if bits <= 0 or bits > 32: - raise ValueError(f"Field size must be between 1 and 32: got {bits}") - bitsize += bits - struct_fields.append(field(name, ftype, bits)) - - if bitsize == 0: - raise ValueError(f"Bitfield size cannot be zero") - elif bitsize % 8 != 0: - raise ValueError(f"Bitfield size must be multiple of 8, got {bitsize}") - bytesize = bitsize // 8 - - # Create the struct class - class Bitfield(BigEndianStructure): - _fields_ = struct_fields - - @staticmethod - def marshalledSize() -> int: - return bytesize - - def serialize(self, outputStream: DataOutputStream) -> None: - outputStream.write_bytes(bytes(self)) - - @classmethod - def parse(cls, inputStream: DataInputStream) -> "Bitfield": - return cls.from_buffer_copy(inputStream.read_bytes(bytesize)) - - # Sanity check: ensure the struct size matches expected size - assert sizeof(Bitfield) == bytesize, \ - f"Bitfield size mismatch: expected {bytesize}, got {sizeof(Bitfield)}" - - # Assign the class name - Bitfield.__name__ = name - return Bitfield - class ModulationType: """Section 6.2.59 @@ -155,11 +65,11 @@ class NetId: YY = Frequency Table """ - _struct = _bitfield(name="NetId", fields=[ - ("netNumber", INTEGER, 10), - ("frequencyTable", INTEGER, 2), - ("mode", INTEGER, 2), - ("padding", INTEGER, 2) + _struct = bitfield.bitfield(name="NetId", fields=[ + ("netNumber", bitfield.INTEGER, 10), + ("frequencyTable", bitfield.INTEGER, 2), + ("mode", bitfield.INTEGER, 2), + ("padding", bitfield.INTEGER, 2) ]) def __init__(self, @@ -207,11 +117,11 @@ class SpreadSpectrum: In Python, the presence or absence of each technique is indicated by a bool. """ - _struct = _bitfield(name="SpreadSpectrum", fields=[ - ("frequencyHopping", INTEGER, 1), - ("pseudoNoise", INTEGER, 1), - ("timeHopping", INTEGER, 1), - ("padding", INTEGER, 13) + _struct = bitfield.bitfield(name="SpreadSpectrum", fields=[ + ("frequencyHopping", bitfield.INTEGER, 1), + ("pseudoNoise", bitfield.INTEGER, 1), + ("timeHopping", bitfield.INTEGER, 1), + ("padding", bitfield.INTEGER, 13) ]) def __init__(self, From ef3bd3b872d5275b0d8a4d7219b5f1bc7910deee Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 03:51:22 +0000 Subject: [PATCH 03/17] chore: remove unused ModulationParameters class --- opendis/record.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/opendis/record.py b/opendis/record.py index 2bd0fb9..733edfa 100644 --- a/opendis/record.py +++ b/opendis/record.py @@ -269,40 +269,6 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding = inputStream.read_uint32() -class ModulationParameters: - """Section 6.2.58 - - Modulation parameters associated with a specific radio system. - - This class is not designed to be instantiated directly with no arguments. - """ - - def __init__(self, - # record must be provided as there is no default value. - record: ModulationParametersRecord): - self.record = record - # ModulationParameters requires padding to 64-bit (8-byte) boundary - self.padding = 8 - (self.record.marshalledSize() % 8) % 8 - - def marshalledSize(self) -> int: - return self.record.marshalledSize() + self.padding - - def serialize(self, outputStream: DataOutputStream) -> None: - self.record.serialize(outputStream) - outputStream.write_bytes(b'\x00' * self.padding) - - def parse(self, inputStream: DataInputStream) -> None: - self.record.parse(inputStream) - bytes_to_read = 8 - (self.record.marshalledSize() % 8) % 8 - if bytes_to_read > 0: - self.padding = int.from_bytes( - inputStream.read_bytes(bytes_to_read), - byteorder='big' - ) - else: - self.padding = 0 - - class AntennaPatternRecord: """Section 6.2.8 From db60ac0a1307eb42b95deecbae5167dcc9303cd4 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 03:52:55 +0000 Subject: [PATCH 04/17] refactor: explicitly define ModulationParametersRecord and AntennaPatternRecord as abstract base classes In Python, this means these classes cannot be instantiated on their own. Subclasses are expected to provide concrete implementations of the abstract base class. --- opendis/record.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/opendis/record.py b/opendis/record.py index 733edfa..8cbdc4c 100644 --- a/opendis/record.py +++ b/opendis/record.py @@ -3,7 +3,7 @@ This module defines classes for various record types used in DIS PDUs. """ -from abc import abstractmethod +from abc import ABC, abstractmethod import bitfield from .stream import DataInputStream, DataOutputStream @@ -153,7 +153,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.timeHopping = bool(record_bitfield.timeHopping) -class ModulationParametersRecord: +class ModulationParametersRecord(ABC): """Base class for modulation parameters records, as defined in Annex C.""" @abstractmethod @@ -269,7 +269,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding = inputStream.read_uint32() -class AntennaPatternRecord: +class AntennaPatternRecord(ABC): """Section 6.2.8 The total length of each record shall be a multiple of 64 bits. From 1e7dad2e2524006b7f3b7e73ff1d5a63d1ead336 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 04:19:14 +0000 Subject: [PATCH 05/17] docs: update docstrings --- opendis/record.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/opendis/record.py b/opendis/record.py index 8cbdc4c..93fc081 100644 --- a/opendis/record.py +++ b/opendis/record.py @@ -154,7 +154,11 @@ def parse(self, inputStream: DataInputStream) -> None: class ModulationParametersRecord(ABC): - """Base class for modulation parameters records, as defined in Annex C.""" + """6.2.58 Modulation Parameters record + + Base class for modulation parameters records, as defined in Annex C. + The total length of each record shall be a multiple of 64 bits. + """ @abstractmethod def marshalledSize(self) -> int: @@ -270,7 +274,7 @@ def parse(self, inputStream: DataInputStream) -> None: class AntennaPatternRecord(ABC): - """Section 6.2.8 + """6.2.8 Antenna Pattern record The total length of each record shall be a multiple of 64 bits. """ From 4308ab714605da362fe17cceff837a531b1db4b3 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 04:20:50 +0000 Subject: [PATCH 06/17] refactor: migrate EulerAngles and BeamAntennaPattern classes to record namespace --- opendis/dis7.py | 87 +------------------------------------------- opendis/record.py | 93 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 85 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 765f20d..1a3be29 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -10,6 +10,8 @@ ModulationParametersRecord, UnknownRadio, UnknownAntennaPattern, + EulerAngles, + BeamAntennaPattern, ) from .stream import DataInputStream, DataOutputStream from .types import ( @@ -678,34 +680,6 @@ def parse(self, inputStream): self.stationNumber = inputStream.read_unsigned_short() -class EulerAngles: - """Section 6.2.33 - - Three floating point values representing an orientation, psi, theta, - and phi, aka the euler angles, in radians. - """ - - def __init__(self, - psi: float32 = 0.0, - theta: float32 = 0.0, - phi: float32 = 0.0): # in radians - self.psi = psi - self.theta = theta - self.phi = phi - - def serialize(self, outputStream): - """serialize the class""" - outputStream.write_float(self.psi) - outputStream.write_float(self.theta) - outputStream.write_float(self.phi) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.psi = inputStream.read_float() - self.theta = inputStream.read_float() - self.phi = inputStream.read_float() - - class DirectedEnergyPrecisionAimpoint: """Section 6.2.20.3 @@ -843,63 +817,6 @@ def parse(self, inputStream): self.padding = inputStream.read_unsigned_byte() -class BeamAntennaPattern: - """Section 6.2.9.2 - - Used when the antenna pattern type field has a value of 1. Specifies the - direction, pattern, and polarization of radiation from an antenna. - """ - - def __init__(self, - beamDirection: "EulerAngles | None" = None, - azimuthBeamwidth: float32 = 0.0, # in radians - elevationBeamwidth: float32 = 0.0, # in radians - referenceSystem: enum8 = 0, # [UID 168] - ez: float32 = 0.0, - ex: float32 = 0.0, - phase: float32 = 0.0): # in radians - self.beamDirection = EulerAngles() - """The rotation that transforms the reference coordinate sytem into the beam coordinate system. Either world coordinates or entity coordinates may be used as the reference coordinate system, as specified by the reference system field of the antenna pattern record.""" - self.azimuthBeamwidth = azimuthBeamwidth - self.elevationBeamwidth = elevationBeamwidth - self.referenceSystem = referenceSystem - self.padding1: uint8 = 0 - self.padding2: uint16 = 0 - self.ez = ez - """This field shall specify the magnitude of the Z-component (in beam coordinates) of the Electrical field at some arbitrary single point in the main beam and in the far field of the antenna.""" - self.ex = ex - """This field shall specify the magnitude of the X-component (in beam coordinates) of the Electrical field at some arbitrary single point in the main beam and in the far field of the antenna.""" - self.phase = phase - """This field shall specify the phase angle between EZ and EX in radians. If fully omni-directional antenna is modeled using beam pattern type one, the omni-directional antenna shall be represented by beam direction Euler angles psi, theta, and phi of zero, an azimuth beamwidth of 2PI, and an elevation beamwidth of PI""" - self.padding3: uint32 = 0 - - def serialize(self, outputStream): - """serialize the class""" - self.beamDirection.serialize(outputStream) - outputStream.write_float(self.azimuthBeamwidth) - outputStream.write_float(self.elevationBeamwidth) - outputStream.write_unsigned_byte(self.referenceSystem) - outputStream.write_unsigned_byte(self.padding1) - outputStream.write_unsigned_short(self.padding2) - outputStream.write_float(self.ez) - outputStream.write_float(self.ex) - outputStream.write_float(self.phase) - outputStream.write_unsigned_int(self.padding3) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.beamDirection.parse(inputStream) - self.azimuthBeamwidth = inputStream.read_float() - self.elevationBeamwidth = inputStream.read_float() - self.referenceSystem = inputStream.read_unsigned_byte() - self.padding1 = inputStream.read_unsigned_byte() - self.padding2 = inputStream.read_unsigned_short() - self.ez = inputStream.read_float() - self.ex = inputStream.read_float() - self.phase = inputStream.read_float() - self.padding3 = inputStream.read_unsigned_int() - - class AttachedParts: """Section 6.2.93.3 diff --git a/opendis/record.py b/opendis/record.py index 93fc081..27730ee 100644 --- a/opendis/record.py +++ b/opendis/record.py @@ -8,16 +8,51 @@ import bitfield from .stream import DataInputStream, DataOutputStream from .types import ( + enum8, enum16, bf_enum, bf_int, bf_uint, + float32, uint8, uint16, uint32, ) +class EulerAngles: + """Section 6.2.32 Euler Angles record + + Three floating point values representing an orientation, psi, theta, + and phi, aka the euler angles, in radians. + These angles shall be specified with respect to the entity's coordinate + system. + """ + + def __init__(self, + psi: float32 = 0.0, + theta: float32 = 0.0, + phi: float32 = 0.0): # in radians + self.psi = psi + self.theta = theta + self.phi = phi + + def marshalledSize(self) -> int: + return 12 + + def serialize(self, outputStream): + """serialize the class""" + outputStream.write_float32(self.psi) + outputStream.write_float32(self.theta) + outputStream.write_float32(self.phi) + + def parse(self, inputStream): + """Parse a message. This may recursively call embedded objects.""" + self.psi = inputStream.read_float32() + self.theta = inputStream.read_float32() + self.phi = inputStream.read_float32() + + class ModulationType: """Section 6.2.59 @@ -310,3 +345,61 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, bytelength: int = 0) -> None: """Parse a message. This may recursively call embedded objects.""" self.data = inputStream.read_bytes(bytelength) + + +class BeamAntennaPattern(AntennaPatternRecord): + """6.2.8.2 Beam Antenna Pattern record + + Used when the antenna pattern type field has a value of 1. Specifies the + direction, pattern, and polarization of radiation from an antenna. + """ + + def __init__(self, + beamDirection: "EulerAngles | None" = None, + azimuthBeamwidth: float32 = 0.0, # in radians + elevationBeamwidth: float32 = 0.0, # in radians + referenceSystem: enum8 = 0, # [UID 168] + ez: float32 = 0.0, + ex: float32 = 0.0, + phase: float32 = 0.0): # in radians + self.beamDirection = beamDirection or EulerAngles() + """The rotation that transforms the reference coordinate sytem into the beam coordinate system. Either world coordinates or entity coordinates may be used as the reference coordinate system, as specified by the reference system field of the antenna pattern record.""" + self.azimuthBeamwidth = azimuthBeamwidth + self.elevationBeamwidth = elevationBeamwidth + self.referenceSystem = referenceSystem + self.padding1: uint8 = 0 + self.padding2: uint16 = 0 + self.ez = ez + """This field shall specify the magnitude of the Z-component (in beam coordinates) of the Electrical field at some arbitrary single point in the main beam and in the far field of the antenna.""" + self.ex = ex + """This field shall specify the magnitude of the X-component (in beam coordinates) of the Electrical field at some arbitrary single point in the main beam and in the far field of the antenna.""" + self.phase = phase + """This field shall specify the phase angle between EZ and EX in radians. If fully omni-directional antenna is modeled using beam pattern type one, the omni-directional antenna shall be represented by beam direction Euler angles psi, theta, and phi of zero, an azimuth beamwidth of 2PI, and an elevation beamwidth of PI""" + self.padding3: uint32 = 0 + + def marshalledSize(self) -> int: + return 40 + + def serialize(self, outputStream: DataOutputStream) -> None: + self.beamDirection.serialize(outputStream) + outputStream.write_float32(self.azimuthBeamwidth) + outputStream.write_float32(self.elevationBeamwidth) + outputStream.write_uint8(self.referenceSystem) + outputStream.write_uint8(self.padding1) + outputStream.write_uint16(self.padding2) + outputStream.write_float32(self.ez) + outputStream.write_float32(self.ex) + outputStream.write_float32(self.phase) + outputStream.write_uint32(self.padding3) + + def parse(self, inputStream: DataInputStream) -> None: + self.beamDirection.parse(inputStream) + self.azimuthBeamwidth = inputStream.read_float32() + self.elevationBeamwidth = inputStream.read_float32() + self.referenceSystem = inputStream.read_uint8() + self.padding1 = inputStream.read_uint8() + self.padding2 = inputStream.read_uint16() + self.ez = inputStream.read_float32() + self.ex = inputStream.read_float32() + self.phase = inputStream.read_float32() + self.padding3 = inputStream.read_uint32() From 4ff321721bc0228a0bb15f62cc10f09fdd3b9a8a Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 04:24:09 +0000 Subject: [PATCH 07/17] feat: handle BeamAntennaPattern parsing in TransmitterPdu --- opendis/dis7.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 1a3be29..777c725 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -5467,15 +5467,19 @@ def parse(self, inputStream: DataInputStream) -> None: ## Antenna Pattern if antennaPatternLength > 0: - self.antennaPattern = UnknownAntennaPattern() - self.antennaPattern.parse( - inputStream, - bytelength=antennaPatternLength - ) + if self.antennaPatternType == 1: + self.antennaPattern = BeamAntennaPattern() + self.antennaPattern.parse(inputStream) + else: + self.antennaPattern = UnknownAntennaPattern() + self.antennaPattern.parse( + inputStream, + bytelength=antennaPatternLength + ) else: self.antennaPattern = None - - + + ## TODO: Variable Transmitter Parameters class ElectromagneticEmissionsPdu(DistributedEmissionsFamilyPdu): From f62039e4890238b94b5d00a77ee209996c47bea8 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 05:23:18 +0000 Subject: [PATCH 08/17] docs: update docstrings --- opendis/record.py | 119 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/opendis/record.py b/opendis/record.py index 27730ee..e3d2690 100644 --- a/opendis/record.py +++ b/opendis/record.py @@ -10,6 +10,7 @@ from .types import ( enum8, enum16, + enum32, bf_enum, bf_int, bf_uint, @@ -403,3 +404,121 @@ def parse(self, inputStream: DataInputStream) -> None: self.ex = inputStream.read_float32() self.phase = inputStream.read_float32() self.padding3 = inputStream.read_uint32() + + +class VariableTransmitterParametersRecord(ABC): + """6.2.95 Variable Transmitter Parameters record + + One or more VTP records may be associated with a radio system, and the same + VTP record may be associated with multiple radio systems. + Specific VTP records applicable to a radio system are identified in the + subclause that defines the radio system’s unique requirements in Annex C. + The total length of each record shall be a multiple of 64 bits. + """ + recordType: enum32 + + @abstractmethod + def marshalledSize(self) -> int: + """Return the size of the record when serialized.""" + + @abstractmethod + def serialize(self, outputStream: DataOutputStream) -> None: + """Serialize the record to the output stream.""" + + @abstractmethod + def parse(self, inputStream: DataInputStream) -> None: + """Parse the record from the input stream. + + The recordType field is assumed to have been read, so as to identify + the type of VTP record to be parsed, before this method is called. + """ + + +class UnknownVariableTransmitterParameters(VariableTransmitterParametersRecord): + """Placeholder for unknown or unimplemented variable transmitter parameter + types. + """ + recordType: enum32 = 0 + + def __init__(self, recordType: enum32 = 0, data: bytes = b""): + self.recordType = recordType # [UID 66] Variable Parameter Record Type + self.data = data + + def marshalledSize(self) -> int: + return 6 + len(self.data) + + @property + def recordLength(self) -> uint16: + return self.marshalledSize() + + def serialize(self, outputStream: DataOutputStream) -> None: + """serialize the class""" + outputStream.write_uint32(self.recordType) + outputStream.write_uint16(self.recordLength) + outputStream.write_bytes(self.data) + + def parse(self, inputStream: DataInputStream) -> None: + """Parse a message. This may recursively call embedded objects.""" + self.recordType = inputStream.read_uint32() + recordLength = inputStream.read_uint16() + self.data = inputStream.read_bytes(recordLength) + + +class HighFidelityHAVEQUICKRadio(VariableTransmitterParametersRecord): + """Annex C C4.2.3, Table C.4 — High Fidelity HAVE QUICK Radio record""" + recordType: enum32 = 3000 + + def __init__(self, + netId: NetId | None = None, + todTransmitIndicator: enum8 = 0, + todDelta: uint32 = 0, + wod1: uint32 = 0, + wod2: uint32 = 0, + wod3: uint32 = 0, + wod4: uint32 = 0, + wod5: uint32 = 0, + wod6: uint32 = 0): + self.padding1: uint16 = 0 + self.netId = netId or NetId() + self.todTransmitIndicator = todTransmitIndicator + self.padding2: uint8 = 0 + self.todDelta = todDelta + self.wod1 = wod1 + self.wod2 = wod2 + self.wod3 = wod3 + self.wod4 = wod4 + self.wod5 = wod5 + self.wod6 = wod6 + + @property + def recordLength(self) -> uint16: + return self.marshalledSize() + + def marshalledSize(self) -> int: + return 40 + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_uint16(self.padding1) + self.netId.serialize(outputStream) + outputStream.write_uint8(self.todTransmitIndicator) + outputStream.write_uint8(self.padding2) + outputStream.write_uint32(self.todDelta) + outputStream.write_uint32(self.wod1) + outputStream.write_uint32(self.wod2) + outputStream.write_uint32(self.wod3) + outputStream.write_uint32(self.wod4) + outputStream.write_uint32(self.wod5) + outputStream.write_uint32(self.wod6) + + def parse(self, inputStream: DataInputStream) -> None: + self.padding1 = inputStream.read_uint16() + self.netId.parse(inputStream) + self.todTransmitIndicator = inputStream.read_uint8() + self.padding2 = inputStream.read_uint8() + self.todDelta = inputStream.read_uint32() + self.wod1 = inputStream.read_uint32() + self.wod2 = inputStream.read_uint32() + self.wod3 = inputStream.read_uint32() + self.wod4 = inputStream.read_uint32() + self.wod5 = inputStream.read_uint32() + self.wod6 = inputStream.read_uint32() From 748d65c4d3c8ac5195adea7dcd7459ef994d7a06 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 05:23:41 +0000 Subject: [PATCH 09/17] docs: remove unnecessary docstrings --- opendis/record.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/opendis/record.py b/opendis/record.py index e3d2690..1b3babe 100644 --- a/opendis/record.py +++ b/opendis/record.py @@ -452,13 +452,11 @@ def recordLength(self) -> uint16: return self.marshalledSize() def serialize(self, outputStream: DataOutputStream) -> None: - """serialize the class""" outputStream.write_uint32(self.recordType) outputStream.write_uint16(self.recordLength) outputStream.write_bytes(self.data) def parse(self, inputStream: DataInputStream) -> None: - """Parse a message. This may recursively call embedded objects.""" self.recordType = inputStream.read_uint32() recordLength = inputStream.read_uint16() self.data = inputStream.read_bytes(recordLength) From 618bb787edf9b61f5182929ccf38fcf7ff9fd537 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 05:25:59 +0000 Subject: [PATCH 10/17] chore: update imports, replace VariableTransmitterParameters in dis7.py --- opendis/dis7.py | 38 +++++++------------------------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 777c725..001ea5c 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -12,6 +12,12 @@ UnknownAntennaPattern, EulerAngles, BeamAntennaPattern, + GenericRadio, + SimpleIntercomRadio, + BasicHaveQuickMP, + VariableTransmitterParametersRecord, + HighFidelityHAVEQUICKRadio, + UnknownVariableTransmitterParameters, ) from .stream import DataInputStream, DataOutputStream from .types import ( @@ -855,36 +861,6 @@ def parse(self, inputStream): self.parameterValue = inputStream.read_long() -class VariableTransmitterParameters: - """Section 6.2.94 - - Relates to radios. NOT COMPLETE. - """ - - def __init__(self, recordType: enum32 = 0, data: bytes = b""): - self.recordType = recordType # [UID 66] Variable Parameter Record Type - self.data = data - - def marshalledSize(self) -> int: - return 6 + len(self.data) - - @property - def recordLength(self) -> uint16: - return self.marshalledSize() - - def serialize(self, outputStream: DataOutputStream) -> None: - """serialize the class""" - outputStream.write_uint32(self.recordType) - outputStream.write_uint16(self.recordLength) - outputStream.write_bytes(self.data) - - def parse(self, inputStream: DataInputStream) -> None: - """Parse a message. This may recursively call embedded objects.""" - self.recordType = inputStream.read_uint32() - recordLength = inputStream.read_uint16() - self.data = inputStream.read_bytes(recordLength) - - class Attribute: """Section 6.2.10. @@ -5353,7 +5329,7 @@ def __init__(self, cryptoKeyId: struct16 = 0, # See Table 175 modulationParameters: ModulationParametersRecord | None = None, antennaPattern: AntennaPatternRecord | None = None, - variableTransmitterParameters: Sequence[VariableTransmitterParameters] | None = None): + variableTransmitterParameters: Sequence[VariableTransmitterParametersRecord] | None = None): super(TransmitterPdu, self).__init__() self.radioReferenceID = radioReferenceID or EntityID() """ID of the entity that is the source of the communication""" From 63c925db8330d8498c47936127d1888c9fd22fb1 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 05:26:32 +0000 Subject: [PATCH 11/17] feat: add CCTTSincgarsMP record class --- opendis/dis7.py | 1 + opendis/record.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/opendis/dis7.py b/opendis/dis7.py index 001ea5c..14ac632 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -15,6 +15,7 @@ GenericRadio, SimpleIntercomRadio, BasicHaveQuickMP, + CCTTSincgarsMP, VariableTransmitterParametersRecord, HighFidelityHAVEQUICKRadio, UnknownVariableTransmitterParameters, diff --git a/opendis/record.py b/opendis/record.py index 1b3babe..4804aa2 100644 --- a/opendis/record.py +++ b/opendis/record.py @@ -309,6 +309,50 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding = inputStream.read_uint32() +class CCTTSincgarsMP(ModulationParametersRecord): + """Annex C 6.2.3, Table C.7 — CCTT SINCGARS MP record""" + + def __init__(self, + fh_net_id: uint16 = 0, + hop_set_id: uint16 = 0, + lockout_set_id: uint16 = 0, + start_of_message: enum8 = 0, + clear_channel: enum8 = 0, + fh_sync_time_offset: uint32 = 0, + transmission_security_key: uint16 = 0): + self.fh_net_id = fh_net_id + self.hop_set_id = hop_set_id + self.lockout_set_id = lockout_set_id + self.start_of_message = start_of_message + self.clear_channel = clear_channel + self.fh_sync_time_offset = fh_sync_time_offset + self.transmission_security_key = transmission_security_key + self.padding: uint16 = 0 + + def marshalledSize(self) -> int: + return 16 # bytes + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_uint16(self.fh_net_id) + outputStream.write_uint16(self.hop_set_id) + outputStream.write_uint16(self.lockout_set_id) + outputStream.write_uint8(self.start_of_message) + outputStream.write_uint8(self.clear_channel) + outputStream.write_uint32(self.fh_sync_time_offset) + outputStream.write_uint16(self.transmission_security_key) + outputStream.write_uint16(self.padding) + + def parse(self, inputStream: DataInputStream) -> None: + self.fh_net_id = inputStream.read_uint16() + self.hop_set_id = inputStream.read_uint16() + self.lockout_set_id = inputStream.read_uint16() + self.start_of_message = inputStream.read_uint8() + self.clear_channel = inputStream.read_uint8() + self.fh_sync_time_offset = inputStream.read_uint32() + self.transmission_security_key = inputStream.read_uint16() + self.padding = inputStream.read_uint16() + + class AntennaPatternRecord(ABC): """6.2.8 Antenna Pattern record From c8e137bcd79e541f0166b341ece576afb460779d Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 05:27:39 +0000 Subject: [PATCH 12/17] feat: parse modulation parameters for GenericRadio, SimpleIntercomRadio, HAVE QUICK radios, CCTT Sincgars radios --- opendis/dis7.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 14ac632..04fc04e 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -5436,8 +5436,21 @@ def parse(self, inputStream: DataInputStream) -> None: ## Modulation Parameters if modulationParametersLength > 0: - radio = UnknownRadio() - radio.parse(inputStream, bytelength=modulationParametersLength) + if self.modulationType.radioSystem == 1: # Generic | Simple Intercom + if self.modulationType.majorModulation == 0: + radio = SimpleIntercomRadio() + else: + radio = GenericRadio() + radio.parse(inputStream) + elif self.modulationType.radioSystem in (2, 3, 4): # HAVE QUICK I | II | HAVE QUICK IIA + radio = BasicHaveQuickMP() + radio.parse(inputStream) + elif self.modulationType.radioSystem == 6: # CCTT SINCGARS + radio = CCTTSincgarsMP() + radio.parse(inputStream) + else: # Other | Unknown + radio = UnknownRadio() + radio.parse(inputStream, bytelength=modulationParametersLength) self.modulationParameters = radio else: self.modulationParameters = None From 88b75f23093d8e64c6efb99ea07e07b02941c7b8 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 05:28:05 +0000 Subject: [PATCH 13/17] feat: parse variable transmitter parameters for High Fidelity HAVE QUICK radios --- opendis/dis7.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/opendis/dis7.py b/opendis/dis7.py index 04fc04e..7c9bb3e 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -5470,6 +5470,16 @@ def parse(self, inputStream: DataInputStream) -> None: self.antennaPattern = None ## TODO: Variable Transmitter Parameters + for _ in range(0, variableTransmitterParameterCount): + recordType = inputStream.read_uint32() + if recordType == 3000: # High Fidelity HAVE QUICK/SATURN Radio + vtp = HighFidelityHAVEQUICKRadio() + vtp.parse(inputStream) + else: # Unknown VTP record type + vtp = UnknownVariableTransmitterParameters() + vtp.recordType = recordType + vtp.parse(inputStream) + self.variableTransmitterParameters.append(vtp) class ElectromagneticEmissionsPdu(DistributedEmissionsFamilyPdu): From f14ac0b481ac41a71152c47575345297722c4240 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 05:28:44 +0000 Subject: [PATCH 14/17] fix: fix type annotation for variableTransmitterParameters --- opendis/dis7.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 7c9bb3e..4757fec 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -5353,7 +5353,11 @@ def __init__(self, self.padding3 = 0 self.modulationParameters = modulationParameters self.antennaPattern = antennaPattern - self.variableTransmitterParameters = variableTransmitterParameters or [] + self.variableTransmitterParameters = ( + list(variableTransmitterParameters) + if variableTransmitterParameters + else [] + ) @property def antennaPatternLength(self) -> uint16: From 97358b673260921ce4a44dd1cf2e1cb4ffbd0d72 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 05:29:12 +0000 Subject: [PATCH 15/17] fix: fix imports --- opendis/record.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendis/record.py b/opendis/record.py index 4804aa2..8079ea1 100644 --- a/opendis/record.py +++ b/opendis/record.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod -import bitfield +from . import bitfield from .stream import DataInputStream, DataOutputStream from .types import ( enum8, From 418f50e3b7dce40dcf0dff89335710336ab9afca Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 05:36:13 +0000 Subject: [PATCH 16/17] refactor: move bitfield under record namespace --- opendis/{record.py => record/__init__.py} | 4 ++-- opendis/{ => record}/bitfield.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) rename opendis/{record.py => record/__init__.py} (99%) rename opendis/{ => record}/bitfield.py (98%) diff --git a/opendis/record.py b/opendis/record/__init__.py similarity index 99% rename from opendis/record.py rename to opendis/record/__init__.py index 8079ea1..04a4039 100644 --- a/opendis/record.py +++ b/opendis/record/__init__.py @@ -6,8 +6,8 @@ from abc import ABC, abstractmethod from . import bitfield -from .stream import DataInputStream, DataOutputStream -from .types import ( +from ..stream import DataInputStream, DataOutputStream +from ..types import ( enum8, enum16, enum32, diff --git a/opendis/bitfield.py b/opendis/record/bitfield.py similarity index 98% rename from opendis/bitfield.py rename to opendis/record/bitfield.py index e0bbb8d..a8f693c 100644 --- a/opendis/bitfield.py +++ b/opendis/record/bitfield.py @@ -17,6 +17,7 @@ from typing import Literal, Sequence from opendis.stream import DataInputStream, DataOutputStream +from opendis.types import bf_enum, bf_int, bf_uint # Type definitions for bitfield field descriptors CTypeFieldDescription = tuple[str, type[_SimpleCData], int] From 29e084ade315729f959fcf9fd95ec9054201ddbb Mon Sep 17 00:00:00 2001 From: JS Ng Date: Thu, 2 Oct 2025 23:24:40 +0000 Subject: [PATCH 17/17] docs: update docstrings --- opendis/dis7.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 4757fec..781cc48 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -5305,11 +5305,11 @@ def parse(self, inputStream): class TransmitterPdu(RadioCommunicationsFamilyPdu): - """Section 7.7.2 + """7.7.2 Transmitter PDU Detailed information about a radio transmitter. This PDU requires manually written code to complete, since the modulation parameters are of variable - length. UNFINISHED + length. """ pduType: enum8 = 25 # [UID 4]