From c9ba0c9481bbe33980e36a98f51fae7d151a31c5 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 06:50:55 +0000 Subject: [PATCH 01/37] refactor: make all Record classes inherit from a Record base class interface This helps to enforce a consistent interface across all Record subclasses with the help of a static checker --- opendis/record/__init__.py | 36 +++++++++++---------------- opendis/record/base.py | 51 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 22 deletions(-) create mode 100644 opendis/record/base.py diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 04a4039..f5c0a23 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -3,9 +3,9 @@ This module defines classes for various record types used in DIS PDUs. """ -from abc import ABC, abstractmethod +from abc import abstractmethod -from . import bitfield +from . import base, bitfield from ..stream import DataInputStream, DataOutputStream from ..types import ( enum8, @@ -21,7 +21,7 @@ ) -class EulerAngles: +class EulerAngles(base.Record): """Section 6.2.32 Euler Angles record Three floating point values representing an orientation, psi, theta, @@ -41,20 +41,18 @@ def __init__(self, def marshalledSize(self) -> int: return 12 - def serialize(self, outputStream): - """serialize the class""" + def serialize(self, outputStream: DataOutputStream) -> None: 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.""" + def parse(self, inputStream: DataInputStream) -> None: self.psi = inputStream.read_float32() self.theta = inputStream.read_float32() self.phi = inputStream.read_float32() -class ModulationType: +class ModulationType(base.Record): """Section 6.2.59 Information about the type of modulation used for radio transmission. @@ -92,7 +90,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.radioSystem = inputStream.read_uint16() -class NetId: +class NetId(base.Record): """Annex C, Table C.5 Represents an Operational Net in the format of NXX.XYY, where: @@ -138,7 +136,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding = record_bitfield.padding -class SpreadSpectrum: +class SpreadSpectrum(base.Record): """6.2.59 Modulation Type Record, Table 90 Modulation used for radio transmission is characterized in a generic @@ -189,7 +187,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.timeHopping = bool(record_bitfield.timeHopping) -class ModulationParametersRecord(ABC): +class ModulationParametersRecord(base.Record): """6.2.58 Modulation Parameters record Base class for modulation parameters records, as defined in Annex C. @@ -198,18 +196,15 @@ class ModulationParametersRecord(ABC): @abstractmethod def marshalledSize(self) -> int: - """Return the size of the record when serialized.""" - raise NotImplementedError() + """Return the size (in bytes) of the record when serialized.""" @abstractmethod def serialize(self, outputStream: DataOutputStream) -> None: """Serialize the record to the output stream.""" - raise NotImplementedError() @abstractmethod def parse(self, inputStream: DataInputStream) -> None: """Parse the record from the input stream.""" - raise NotImplementedError() class UnknownRadio(ModulationParametersRecord): @@ -353,7 +348,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding = inputStream.read_uint16() -class AntennaPatternRecord(ABC): +class AntennaPatternRecord(base.Record): """6.2.8 Antenna Pattern record The total length of each record shall be a multiple of 64 bits. @@ -361,18 +356,15 @@ class AntennaPatternRecord(ABC): @abstractmethod def marshalledSize(self) -> int: - """Return the size of the record when serialized.""" - raise NotImplementedError() + """Return the size (in bytes) of the record when serialized.""" @abstractmethod def serialize(self, outputStream: DataOutputStream) -> None: """Serialize the record to the output stream.""" - raise NotImplementedError() @abstractmethod def parse(self, inputStream: DataInputStream) -> None: """Parse the record from the input stream.""" - raise NotImplementedError() class UnknownAntennaPattern(AntennaPatternRecord): @@ -450,7 +442,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding3 = inputStream.read_uint32() -class VariableTransmitterParametersRecord(ABC): +class VariableTransmitterParametersRecord(base.Record): """6.2.95 Variable Transmitter Parameters record One or more VTP records may be associated with a radio system, and the same @@ -463,7 +455,7 @@ class VariableTransmitterParametersRecord(ABC): @abstractmethod def marshalledSize(self) -> int: - """Return the size of the record when serialized.""" + """Return the size (in bytes) of the record when serialized.""" @abstractmethod def serialize(self, outputStream: DataOutputStream) -> None: diff --git a/opendis/record/base.py b/opendis/record/base.py new file mode 100644 index 0000000..03e19c9 --- /dev/null +++ b/opendis/record/base.py @@ -0,0 +1,51 @@ +"""Base classes for all Record types.""" + +__all__ = ["Record", "VariableRecord"] + +from abc import abstractmethod +from typing import Protocol + +from opendis.stream import DataInputStream, DataOutputStream + + +class Record(Protocol): + """Base class for all Record types. + + This base class defines the interface for DIS records with fixed sizes. + """ + + @abstractmethod + def marshalledSize(self) -> int: + """Return the size (in bytes) 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.""" + + +class VariableRecord(Protocol): + """Base class for all Variable Record types. + + This base class defines the interface for DIS records with variable sizes. + """ + + @abstractmethod + def marshalledSize(self) -> int: + """Return the size (in bytes) 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, + bytelength: int | None = None) -> None: + """Parse the record from the input stream. + + If bytelength is provided, it indicates the expected length of the record. Some records may require this information to parse correctly. + """ From 5173b9d18ebbde9876551af4146e8d73be2a5ede Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 07:16:31 +0000 Subject: [PATCH 02/37] feat: add static method for validating bytelength parameter --- opendis/record/base.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/opendis/record/base.py b/opendis/record/base.py index 03e19c9..5888bfa 100644 --- a/opendis/record/base.py +++ b/opendis/record/base.py @@ -3,7 +3,7 @@ __all__ = ["Record", "VariableRecord"] from abc import abstractmethod -from typing import Protocol +from typing import Any, Protocol, TypeGuard from opendis.stream import DataInputStream, DataOutputStream @@ -33,6 +33,11 @@ class VariableRecord(Protocol): This base class defines the interface for DIS records with variable sizes. """ + @staticmethod + def is_positive_int(value: Any) -> TypeGuard[int]: + """Check if a value is a positive integer.""" + return isinstance(value, int) and value >= 0 + @abstractmethod def marshalledSize(self) -> int: """Return the size (in bytes) of the record when serialized.""" From 37b3f4a72571fa56a0bd4aaac9614502d7bddff1 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 07:16:34 +0000 Subject: [PATCH 03/37] fix: handle variable-size record classes correctly by subclassing VariableRecord --- opendis/record/__init__.py | 84 ++++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index f5c0a23..ac2212f 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -187,7 +187,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.timeHopping = bool(record_bitfield.timeHopping) -class ModulationParametersRecord(base.Record): +class ModulationParametersRecord(base.VariableRecord): """6.2.58 Modulation Parameters record Base class for modulation parameters records, as defined in Annex C. @@ -203,8 +203,17 @@ 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.""" + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: + """Parse the record from the input stream. + + If bytelength is provided, it indicates the expected length of the record. Some records may require this information to parse correctly. + """ + if not self.is_positive_int(bytelength): + raise ValueError( + f"bytelength must be a non-negative integer, got {bytelength!r}" + ) class UnknownRadio(ModulationParametersRecord): @@ -219,7 +228,12 @@ def marshalledSize(self) -> int: def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_bytes(self.data) - def parse(self, inputStream: DataInputStream, bytelength: int = 0) -> None: + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = 0) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + assert isinstance(bytelength, int) self.data = inputStream.read_bytes(bytelength) @@ -236,7 +250,9 @@ def marshalledSize(self) -> int: def serialize(self, outputStream: DataOutputStream) -> None: pass - def parse(self, inputStream: DataInputStream) -> None: + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: pass @@ -257,7 +273,9 @@ def marshalledSize(self) -> int: def serialize(self, outputStream: DataOutputStream) -> None: pass - def parse(self, inputStream: DataInputStream) -> None: + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: pass @@ -294,7 +312,9 @@ def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_uint32(self.time_of_day) outputStream.write_uint32(self.padding) - def parse(self, inputStream: DataInputStream) -> None: + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: self.net_id.parse(inputStream) self.mwod_index = inputStream.read_uint16() self.reserved16 = inputStream.read_uint16() @@ -337,7 +357,9 @@ def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_uint16(self.transmission_security_key) outputStream.write_uint16(self.padding) - def parse(self, inputStream: DataInputStream) -> None: + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: self.fh_net_id = inputStream.read_uint16() self.hop_set_id = inputStream.read_uint16() self.lockout_set_id = inputStream.read_uint16() @@ -348,7 +370,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding = inputStream.read_uint16() -class AntennaPatternRecord(base.Record): +class AntennaPatternRecord(base.VariableRecord): """6.2.8 Antenna Pattern record The total length of each record shall be a multiple of 64 bits. @@ -363,8 +385,15 @@ 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.""" + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: + """Parse the record from the input stream. + + The recordType field is assumed to have been read, so as to identify + the type of Antenna Pattern record to be parsed, before this method is + called. + """ class UnknownAntennaPattern(AntennaPatternRecord): @@ -379,8 +408,12 @@ def marshalledSize(self) -> int: def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_bytes(self.data) - def parse(self, inputStream: DataInputStream, bytelength: int = 0) -> None: - """Parse a message. This may recursively call embedded objects.""" + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = 0) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + assert isinstance(bytelength, int) self.data = inputStream.read_bytes(bytelength) @@ -429,7 +462,9 @@ def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_float32(self.phase) outputStream.write_uint32(self.padding3) - def parse(self, inputStream: DataInputStream) -> None: + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: self.beamDirection.parse(inputStream) self.azimuthBeamwidth = inputStream.read_float32() self.elevationBeamwidth = inputStream.read_float32() @@ -442,7 +477,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding3 = inputStream.read_uint32() -class VariableTransmitterParametersRecord(base.Record): +class VariableTransmitterParametersRecord(base.VariableRecord): """6.2.95 Variable Transmitter Parameters record One or more VTP records may be associated with a radio system, and the same @@ -462,12 +497,18 @@ def serialize(self, outputStream: DataOutputStream) -> None: """Serialize the record to the output stream.""" @abstractmethod - def parse(self, inputStream: DataInputStream) -> None: + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> 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. """ + if not self.is_positive_int(bytelength): + raise ValueError( + f"bytelength must be a non-negative integer, got {bytelength!r}" + ) class UnknownVariableTransmitterParameters(VariableTransmitterParametersRecord): @@ -492,7 +533,12 @@ def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_uint16(self.recordLength) outputStream.write_bytes(self.data) - def parse(self, inputStream: DataInputStream) -> None: + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = 0) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + assert isinstance(bytelength, int) self.recordType = inputStream.read_uint32() recordLength = inputStream.read_uint16() self.data = inputStream.read_bytes(recordLength) @@ -544,7 +590,9 @@ def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_uint32(self.wod5) outputStream.write_uint32(self.wod6) - def parse(self, inputStream: DataInputStream) -> None: + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: self.padding1 = inputStream.read_uint16() self.netId.parse(inputStream) self.todTransmitIndicator = inputStream.read_uint8() From 0f8785f15244f116560bcb73fff857ec2f52ccff Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 07:19:52 +0000 Subject: [PATCH 04/37] fix: parse VTPs correctly by reading recordLength first --- opendis/dis7.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 781cc48..e47c136 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -5414,7 +5414,6 @@ def serialize(self, outputStream: DataOutputStream) -> None: vtp.serialize(outputStream) def parse(self, inputStream: DataInputStream) -> None: - """Parse a message. This may recursively call embedded objects.""" super(TransmitterPdu, self).parse(inputStream) self.radioReferenceID.parse(inputStream) self.radioNumber = inputStream.read_uint16() @@ -5473,16 +5472,14 @@ def parse(self, inputStream: DataInputStream) -> None: else: self.antennaPattern = None - ## TODO: Variable Transmitter Parameters for _ in range(0, variableTransmitterParameterCount): recordType = inputStream.read_uint32() + recordLength = inputStream.read_uint16() 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) + vtp = UnknownVariableTransmitterParameters(recordType) + vtp.parse(inputStream, bytelength=recordLength) self.variableTransmitterParameters.append(vtp) From b19715ad2fb6abb044a9f8ff3eb54f232987b938 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 07:46:51 +0000 Subject: [PATCH 05/37] feat: make Record and VariableRecord checkable with isinstance() and issubclass() --- opendis/record/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/opendis/record/base.py b/opendis/record/base.py index 5888bfa..2564839 100644 --- a/opendis/record/base.py +++ b/opendis/record/base.py @@ -3,11 +3,12 @@ __all__ = ["Record", "VariableRecord"] from abc import abstractmethod -from typing import Any, Protocol, TypeGuard +from typing import Any, Protocol, TypeGuard, runtime_checkable from opendis.stream import DataInputStream, DataOutputStream +@runtime_checkable class Record(Protocol): """Base class for all Record types. @@ -27,6 +28,7 @@ def parse(self, inputStream: DataInputStream) -> None: """Parse the record from the input stream.""" +@runtime_checkable class VariableRecord(Protocol): """Base class for all Variable Record types. From b1682f5cbc786ddd590e0fcc59d1b884f4ff4a30 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 07:48:35 +0000 Subject: [PATCH 06/37] refactor: use getVariableRecordClass getter function to retrieve VariableRecord classes The mapping from recordType enum values to VariableRecord classes is defined in [UID 66]. Instead of scattering information about this mapping throughout the codebase or dis7.py, it would be more readable to concentrate it in this getter function. --- opendis/dis7.py | 12 ++++++++---- opendis/record/__init__.py | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index e47c136..2c77c8c 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -17,8 +17,8 @@ BasicHaveQuickMP, CCTTSincgarsMP, VariableTransmitterParametersRecord, - HighFidelityHAVEQUICKRadio, UnknownVariableTransmitterParameters, + getVariableRecordClass, ) from .stream import DataInputStream, DataOutputStream from .types import ( @@ -5475,9 +5475,13 @@ def parse(self, inputStream: DataInputStream) -> None: for _ in range(0, variableTransmitterParameterCount): recordType = inputStream.read_uint32() recordLength = inputStream.read_uint16() - if recordType == 3000: # High Fidelity HAVE QUICK/SATURN Radio - vtp = HighFidelityHAVEQUICKRadio() - else: # Unknown VTP record type + vtpClass = getVariableRecordClass( + recordType, + expectedType=VariableTransmitterParametersRecord + ) + if vtpClass: + vtp = vtpClass() + else: vtp = UnknownVariableTransmitterParameters(recordType) vtp.parse(inputStream, bytelength=recordLength) self.variableTransmitterParameters.append(vtp) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index ac2212f..2e8a267 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -4,6 +4,7 @@ """ from abc import abstractmethod +from typing import TypeVar from . import base, bitfield from ..stream import DataInputStream, DataOutputStream @@ -20,6 +21,8 @@ uint32, ) +VR = TypeVar('VR', bound=base.VariableRecord) + class EulerAngles(base.Record): """Section 6.2.32 Euler Angles record @@ -604,3 +607,26 @@ def parse(self, self.wod4 = inputStream.read_uint32() self.wod5 = inputStream.read_uint32() self.wod6 = inputStream.read_uint32() + + +__variableRecordClasses: dict[int, type[base.VariableRecord]] = { + 3000: HighFidelityHAVEQUICKRadio, +} + +def getVariableRecordClass( + recordType: int, + expectedType: type[VR] = base.VariableRecord +) -> type[VR] | None: + if not isinstance(recordType, int) or recordType < 0: + raise ValueError( + f"recordType must be a non-negative integer, got {recordType!r}" + ) + vrClass = __variableRecordClasses.get(recordType, None) + if vrClass is None: + return None + if not issubclass(vrClass, expectedType): + raise TypeError( + f"Record Type {recordType}: Record class {vrClass.__name__} is not " + f"a subclass of {expectedType.__name__}" + ) + return vrClass # type: ignore[return-value] From 519ad4779afe62b1651f2b758bfce835386316b5 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 07:56:20 +0000 Subject: [PATCH 07/37] refactor: migrate Vector3Float to record namespace --- opendis/dis7.py | 25 +------------------------ opendis/record/__init__.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 2c77c8c..f60cfb7 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -11,6 +11,7 @@ UnknownRadio, UnknownAntennaPattern, EulerAngles, + Vector3Float, BeamAntennaPattern, GenericRadio, SimpleIntercomRadio, @@ -1819,30 +1820,6 @@ def parse(self, inputStream): self.directedEnergyTargetEnergyDepositionRecordList.append(element) -class Vector3Float: - """Section 6.2.95 - - Three floating point values, x, y, and z. - """ - - def __init__(self, x: float32 = 0.0, y: float32 = 0.0, z: float32 = 0.0): - self.x = x - self.y = y - self.z = z - - def serialize(self, outputStream): - """serialize the class""" - outputStream.write_float(self.x) - outputStream.write_float(self.y) - outputStream.write_float(self.z) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.x = inputStream.read_float() - self.y = inputStream.read_float() - self.z = inputStream.read_float() - - class Expendable: """Section 6.2.36 diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 2e8a267..fb102b9 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -24,8 +24,37 @@ VR = TypeVar('VR', bound=base.VariableRecord) +class Vector3Float(base.Record): + """6.2.96 Vector record + + Vector values for entity coordinates, linear acceleration, and linear + velocity shall be represented using a Vector record. This record shall + consist of three fields, each a 32-bit floating point number. + The unit of measure represented by these fields shall depend on the + information represented. + """ + + def __init__(self, x: float32 = 0.0, y: float32 = 0.0, z: float32 = 0.0): + self.x = x + self.y = y + self.z = z + + def marshalledSize(self) -> int: + return 12 + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_float(self.x) + outputStream.write_float(self.y) + outputStream.write_float(self.z) + + def parse(self, inputStream: DataInputStream) -> None: + self.x = inputStream.read_float() + self.y = inputStream.read_float() + self.z = inputStream.read_float() + + class EulerAngles(base.Record): - """Section 6.2.32 Euler Angles record + """6.2.32 Euler Angles record Three floating point values representing an orientation, psi, theta, and phi, aka the euler angles, in radians. From dd5886250c6de1d4d24f3c046555c71197038932 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 08:06:03 +0000 Subject: [PATCH 08/37] refactor: migrate SimulationAddress and EventIdentifier to record namespace --- opendis/dis7.py | 2 ++ opendis/record/__init__.py | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/opendis/dis7.py b/opendis/dis7.py index f60cfb7..9726126 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -12,6 +12,8 @@ UnknownAntennaPattern, EulerAngles, Vector3Float, + EventIdentifier, + SimulationAddress, BeamAntennaPattern, GenericRadio, SimpleIntercomRadio, diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index fb102b9..49f875a 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -16,6 +16,7 @@ bf_int, bf_uint, float32, + struct8, uint8, uint16, uint32, @@ -84,6 +85,60 @@ def parse(self, inputStream: DataInputStream) -> None: self.phi = inputStream.read_float32() +class SimulationAddress(base.Record): + """6.2.80 Simulation Address record + + Simulation designation associated with all object identifiers except + those contained in Live Entity PDUs. + """ + + def __init__(self, + site: uint16 = 0, + application: uint16 = 0): + self.site = site + """A site is defined as a facility, installation, organizational unit or a geographic location that has one or more simulation applications capable of participating in a distributed event.""" + self.application = application + """An application is defined as a software program that is used to generate and process distributed simulation data including live, virtual and constructive data.""" + + def marshalledSize(self) -> int: + return 4 + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_unsigned_short(self.site) + outputStream.write_unsigned_short(self.application) + + def parse(self, inputStream: DataInputStream) -> None: + self.site = inputStream.read_unsigned_short() + self.application = inputStream.read_unsigned_short() + + +class EventIdentifier(base.Record): + """6.2.33 Event Identifier record + + Identifies an event in the world. Use this format for every PDU EXCEPT + the LiveEntityPdu. + """ + # TODO: Distinguish EventIdentifier and LiveEventIdentifier + + def __init__(self, + simulationAddress: "SimulationAddress | None" = None, + eventNumber: uint16 = 0): + self.simulationAddress = simulationAddress or SimulationAddress() + """Site and application IDs""" + self.eventNumber = eventNumber + + def marshalledSize(self) -> int: + return self.simulationAddress.marshalledSize() + 2 + + def serialize(self, outputStream: DataOutputStream) -> None: + self.simulationAddress.serialize(outputStream) + outputStream.write_unsigned_short(self.eventNumber) + + def parse(self, inputStream: DataInputStream) -> None: + self.simulationAddress.parse(inputStream) + self.eventNumber = inputStream.read_unsigned_short() + + class ModulationType(base.Record): """Section 6.2.59 From 998045b9839171452a2465d85bf9cbed9f7f4593 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 08:08:25 +0000 Subject: [PATCH 09/37] refactor: migrate SimulationAddress and EventIdentifier to record namespace --- opendis/dis7.py | 51 ------------------------------------------------- 1 file changed, 51 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 9726126..2d68e0c 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -1959,32 +1959,6 @@ def parse(self, inputStream): self.padding = inputStream.read_unsigned_int() -class SimulationAddress: - """Section 6.2.79 - - A Simulation Address record shall consist of the Site Identification number - and the Application Identification number. - """ - - def __init__(self, - site: uint16 = 0, - application: uint16 = 0): - self.site = site - """A site is defined as a facility, installation, organizational unit or a geographic location that has one or more simulation applications capable of participating in a distributed event.""" - self.application = application - """An application is defined as a software program that is used to generate and process distributed simulation data including live, virtual and constructive data.""" - - def serialize(self, outputStream): - """serialize the class""" - outputStream.write_unsigned_short(self.site) - outputStream.write_unsigned_short(self.application) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.site = inputStream.read_unsigned_short() - self.application = inputStream.read_unsigned_short() - - class SystemIdentifier: """Section 6.2.87 @@ -2453,31 +2427,6 @@ def parse(self, inputStream): self.aggregateType.parse(inputStream) -class EventIdentifier: - """Section 6.2.34 - - Identifies an event in the world. Use this format for every PDU EXCEPT - the LiveEntityPdu. - """ - - def __init__(self, - simulationAddress: "SimulationAddress | None" = None, - eventNumber: uint16 = 0): - self.simulationAddress = simulationAddress or SimulationAddress() - """Site and application IDs""" - self.eventNumber = eventNumber - - def serialize(self, outputStream): - """serialize the class""" - self.simulationAddress.serialize(outputStream) - outputStream.write_unsigned_short(self.eventNumber) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.simulationAddress.parse(inputStream) - self.eventNumber = inputStream.read_unsigned_short() - - class BlankingSector: """Section 6.2.21.2 From 6c1e685a7460842eb09c36285736c18fb8b070f0 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 08:13:29 +0000 Subject: [PATCH 10/37] refactor: migrate DirectedEnergyDamage to record namespace --- opendis/dis7.py | 72 +--------------------- opendis/record/__init__.py | 122 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 69 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 2d68e0c..bba2ac2 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -21,6 +21,9 @@ CCTTSincgarsMP, VariableTransmitterParametersRecord, UnknownVariableTransmitterParameters, + DamageDescriptionRecord, + UnknownDamage, + DirectedEnergyDamage, getVariableRecordClass, ) from .stream import DataInputStream, DataOutputStream @@ -1345,75 +1348,6 @@ def parse(self, inputStream): self.beamwidthDownElevation = inputStream.read_float() -class DirectedEnergyDamage: - """Section 6.2.15.2 - - Damage sustained by an entity due to directed energy. Location of the - damage based on a relative x,y,z location from the center of the entity. - """ - recordType: enum32 = 4500 # [UID 66] Variable Record Type - recordLength: uint16 = 40 # in bytes - - def __init__( - self, - damageLocation: "Vector3Float | None" = None, - damageDiameter: float32 = 0.0, # in metres - temperature: float32 = -273.15, # in degrees Celsius - componentIdentification: enum8 = 0, # [UID 314] - componentDamageStatus: enum8 = 0, # [UID 315] - componentVisualDamageStatus: struct8 = 0, # [UID 317] - componentVisualSmokeColor: enum8 = 0, # [UID 316] - fireEventID: "EventIdentifier | None" = None): - self.padding: uint16 = 0 - self.damageLocation = damageLocation or Vector3Float() - """location of damage, relative to center of entity""" - self.damageDiameter = damageDiameter - """Size of damaged area, in meters.""" - self.temperature = temperature - """average temp of the damaged area, in degrees celsius. If firing entitty does not model this, use a value of -273.15""" - self.componentIdentification = componentIdentification - """enumeration""" - self.componentDamageStatus = componentDamageStatus - """enumeration""" - self.componentVisualDamageStatus = componentVisualDamageStatus - """enumeration""" - self.componentVisualSmokeColor = componentVisualSmokeColor - """enumeration""" - self.fireEventID = fireEventID or EventIdentifier() - """For any component damage resulting this field shall be set to the fire event ID from that PDU.""" - self.padding2: uint16 = 0 - - def serialize(self, outputStream): - """serialize the class""" - outputStream.write_unsigned_int(self.recordType) - outputStream.write_unsigned_short(self.recordLength) - outputStream.write_unsigned_short(self.padding) - self.damageLocation.serialize(outputStream) - outputStream.write_float(self.damageDiameter) - outputStream.write_float(self.temperature) - outputStream.write_unsigned_byte(self.componentIdentification) - outputStream.write_unsigned_byte(self.componentDamageStatus) - outputStream.write_unsigned_byte(self.componentVisualDamageStatus) - outputStream.write_unsigned_byte(self.componentVisualSmokeColor) - self.fireEventID.serialize(outputStream) - outputStream.write_unsigned_short(self.padding2) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.recordType = inputStream.read_unsigned_int() # TODO: validate - self.recordLength = inputStream.read_unsigned_short() # TODO: validate - self.padding = inputStream.read_unsigned_short() - self.damageLocation.parse(inputStream) - self.damageDiameter = inputStream.read_float() - self.temperature = inputStream.read_float() - self.componentIdentification = inputStream.read_unsigned_byte() - self.componentDamageStatus = inputStream.read_unsigned_byte() - self.componentVisualDamageStatus = inputStream.read_unsigned_byte() - self.componentVisualSmokeColor = inputStream.read_unsigned_byte() - self.fireEventID.parse(inputStream) - self.padding2 = inputStream.read_unsigned_short() - - class ExplosionDescriptor: """Section 6.2.19.3 diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 49f875a..2d309ee 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -693,6 +693,128 @@ def parse(self, self.wod6 = inputStream.read_uint32() +class DamageDescriptionRecord(base.VariableRecord): + """6.2.15 Damage Description record + + Damage Description records shall use the Standard Variable record format of + the Standard Variable Specification record (see 6.2.83). + New Damage Description records may be defined at some future date as needed. + """ + + @abstractmethod + def marshalledSize(self) -> int: + """Return the size (in bytes) 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, + bytelength: int | None = None) -> None: + """Parse the record from the input stream. + + recordType and recordLength are assumed to have been read before this method is called. + """ + if not self.is_positive_int(bytelength): + raise ValueError( + f"bytelength must be a non-negative integer, got {bytelength!r}" + ) + + +class UnknownDamage(DamageDescriptionRecord): + """Placeholder for unknown or unimplemented damage description types.""" + + 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: + outputStream.write_uint32(self.recordType) + outputStream.write_uint16(self.recordLength) + outputStream.write_bytes(self.data) + + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = 0) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + assert isinstance(bytelength, int) + self.recordType = inputStream.read_uint32() + recordLength = inputStream.read_uint16() + self.data = inputStream.read_bytes(recordLength) + + +class DirectedEnergyDamage(DamageDescriptionRecord): + """6.2.15.2 Directed Energy Damage Description record + + Damage sustained by an entity due to directed energy. Location of the + damage based on a relative x, y, z location from the center of the entity. + """ + recordType: enum32 = 4500 # [UID 66] Variable Record Type + recordLength: uint16 = 40 # in bytes + + def __init__( + self, + damageLocation: "Vector3Float | None" = None, + damageDiameter: float32 = 0.0, # in metres + temperature: float32 = -273.15, # in degrees Celsius + componentIdentification: enum8 = 0, # [UID 314] + componentDamageStatus: enum8 = 0, # [UID 315] + componentVisualDamageStatus: struct8 = 0, # [UID 317] + componentVisualSmokeColor: enum8 = 0, # [UID 316] + fireEventID: "EventIdentifier | None" = None): + self.padding: uint16 = 0 + self.damageLocation = damageLocation or Vector3Float() + self.damageDiameter = damageDiameter + self.temperature = temperature + self.componentIdentification = componentIdentification + self.componentDamageStatus = componentDamageStatus + self.componentVisualDamageStatus = componentVisualDamageStatus + self.componentVisualSmokeColor = componentVisualSmokeColor + self.fireEventID = fireEventID or EventIdentifier() + self.padding2: uint16 = 0 + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_unsigned_int(self.recordType) + outputStream.write_unsigned_short(self.recordLength) + outputStream.write_unsigned_short(self.padding) + self.damageLocation.serialize(outputStream) + outputStream.write_float(self.damageDiameter) + outputStream.write_float(self.temperature) + outputStream.write_unsigned_byte(self.componentIdentification) + outputStream.write_unsigned_byte(self.componentDamageStatus) + outputStream.write_unsigned_byte(self.componentVisualDamageStatus) + outputStream.write_unsigned_byte(self.componentVisualSmokeColor) + self.fireEventID.serialize(outputStream) + outputStream.write_unsigned_short(self.padding2) + + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = 0) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + assert isinstance(bytelength, int) + self.padding = inputStream.read_unsigned_short() + self.damageLocation.parse(inputStream) + self.damageDiameter = inputStream.read_float() + self.temperature = inputStream.read_float() + self.componentIdentification = inputStream.read_unsigned_byte() + self.componentDamageStatus = inputStream.read_unsigned_byte() + self.componentVisualDamageStatus = inputStream.read_unsigned_byte() + self.componentVisualSmokeColor = inputStream.read_unsigned_byte() + self.fireEventID.parse(inputStream) + self.padding2 = inputStream.read_unsigned_short() + + __variableRecordClasses: dict[int, type[base.VariableRecord]] = { 3000: HighFidelityHAVEQUICKRadio, } From a7b9bb5db048d9f818db1097d02ee29ad65b97c9 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Mon, 6 Oct 2025 08:48:03 +0000 Subject: [PATCH 11/37] refactor: migrate DirectedEnergyRecords to record namespace Not yet working due to null() calls --- opendis/dis7.py | 173 ------------------------------- opendis/record/__init__.py | 202 +++++++++++++++++++++++++++++++++---- 2 files changed, 185 insertions(+), 190 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index bba2ac2..da864eb 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -693,89 +693,6 @@ def parse(self, inputStream): self.stationNumber = inputStream.read_unsigned_short() -class DirectedEnergyPrecisionAimpoint: - """Section 6.2.20.3 - - DE Precision Aimpoint Record. - """ - recordType: enum32 = 4000 - recordLength: uint16 = 88 - - def __init__(self, - targetSpotLocation: "Vector3Double | None" = None, - targetSpotEntityLocation: "Vector3Float | None" = None, - targetSpotVelocity: "Vector3Float | None" = None, - targetSpotAcceleration: "Vector3Float | None" = None, - targetEntityID: "EntityID | None" = None, - targetComponentID: enum8 = 0, # [UID 314] - beamSpotType: enum8 = 0, # [UID 311] - beamSpotCrossSectionSemiMajorAxis: float32 = 0.0, # in meters - beamSpotCrossSectionSemiMinorAxis: float32 = 0.0, # in meters - beamSpotCrossSectionOrientationAngle: float32 = 0.0, # in radians - peakIrradiance: float32 = 0.0): # in W/m^2 - self.padding: uint16 = 0 - self.targetSpotLocation = targetSpotLocation or Vector3Double() - """Position of Target Spot in World Coordinates.""" - self.targetSpotEntityLocation = targetSpotEntityLocation or Vector3Float( - ) - """Position (meters) of Target Spot relative to Entity Position.""" - self.targetSpotVelocity = targetSpotVelocity or Vector3Float() - """Velocity (meters/sec) of Target Spot.""" - self.targetSpotAcceleration = targetSpotAcceleration or Vector3Float() - """Acceleration (meters/sec/sec) of Target Spot.""" - self.targetEntityID = targetEntityID or EntityID() - """Unique ID of the target entity.""" - self.targetComponentID = targetComponentID - """Target Component ID ENUM, same as in DamageDescriptionRecord.""" - self.beamSpotType = beamSpotType - """Spot Shape ENUM.""" - self.beamSpotCrossSectionSemiMajorAxis = beamSpotCrossSectionSemiMajorAxis - """Beam Spot Cross Section Semi-Major Axis.""" - self.beamSpotCrossSectionSemiMinorAxis = beamSpotCrossSectionSemiMinorAxis - """Beam Spot Cross Section Semi-Major Axis.""" - self.beamSpotCrossSectionOrientationAngle = beamSpotCrossSectionOrientationAngle - """Beam Spot Cross Section Orientation Angle.""" - self.peakIrradiance = peakIrradiance - """Peak irradiance""" - self.padding2: uint32 = 0 - - def serialize(self, outputStream): - """serialize the class""" - outputStream.write_unsigned_int(self.recordType) - outputStream.write_unsigned_short(self.recordLength) - outputStream.write_unsigned_short(self.padding) - self.targetSpotLocation.serialize(outputStream) - self.targetSpotEntityLocation.serialize(outputStream) - self.targetSpotVelocity.serialize(outputStream) - self.targetSpotAcceleration.serialize(outputStream) - self.targetEntityID.serialize(outputStream) - outputStream.write_unsigned_byte(self.targetComponentID) - outputStream.write_unsigned_byte(self.beamSpotType) - outputStream.write_float(self.beamSpotCrossSectionSemiMajorAxis) - outputStream.write_float(self.beamSpotCrossSectionSemiMinorAxis) - outputStream.write_float(self.beamSpotCrossSectionOrientationAngle) - outputStream.write_float(self.peakIrradiance) - outputStream.write_unsigned_int(self.padding2) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.recordType = inputStream.read_unsigned_int() # TODO: validate - self.recordLength = inputStream.read_unsigned_short() # TODO: validate - self.padding = inputStream.read_unsigned_short() - self.targetSpotLocation.parse(inputStream) - self.targetSpotEntityLocation.parse(inputStream) - self.targetSpotVelocity.parse(inputStream) - self.targetSpotAcceleration.parse(inputStream) - self.targetEntityID.parse(inputStream) - self.targetComponentID = inputStream.read_unsigned_byte() - self.beamSpotType = inputStream.read_unsigned_byte() - self.beamSpotCrossSectionSemiMajorAxis = inputStream.read_float() - self.beamSpotCrossSectionSemiMinorAxis = inputStream.read_float() - self.beamSpotCrossSectionOrientationAngle = inputStream.read_float() - self.peakIrradiance = inputStream.read_float() - self.padding2 = inputStream.read_unsigned_int() - - class IFFDataSpecification: """Section 6.2.43 @@ -1694,68 +1611,6 @@ def parse(self, inputStream): self.variableDatumRecords.append(element) -class DirectedEnergyAreaAimpoint: - """Section 6.2.20.2 - - DE Precision Aimpoint Record. NOT COMPLETE - """ - recordType: enum32 = 4001 # [UID 66] - - def __init__(self, - recordLength: uint16 = 0, - beamAntennaParameters: list | None = None, - directedEnergyTargetEnergyDepositions: list | None -= None): - """Type of Record enumeration""" - self.recordLength = recordLength - """Length of Record""" - self.padding: uint16 = 0 - self.beamAntennaParameters = beamAntennaParameters or [] - """list of beam antenna records. See 6.2.9.2""" - self.directedEnergyTargetEnergyDepositionRecordList = directedEnergyTargetEnergyDepositions or [] - """list of DE target deposition records. See 6.2.21.4""" - - @property - def beamAntennaPatternRecordCount(self) -> uint16: - return len(self.beamAntennaParameters) - - @property - def directedEnergyTargetEnergyDepositionRecordCount(self) -> uint16: - return len(self.directedEnergyTargetEnergyDepositionRecordList) - - def serialize(self, outputStream): - """serialize the class""" - outputStream.write_unsigned_int(self.recordType) - outputStream.write_unsigned_short(self.recordLength) - outputStream.write_unsigned_short(self.padding) - outputStream.write_unsigned_short(self.beamAntennaPatternRecordCount) - outputStream.write_unsigned_short( - self.directedEnergyTargetEnergyDepositionRecordCount - ) - for anObj in self.beamAntennaParameters: - anObj.serialize(outputStream) - - for anObj in self.directedEnergyTargetEnergyDepositionRecordList: - anObj.serialize(outputStream) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.recordType = inputStream.read_unsigned_int() # TODO: validate - self.recordLength = inputStream.read_unsigned_short() # TODO: validate - self.padding = inputStream.read_unsigned_short() - beamAntennaPatternRecordCount = inputStream.read_unsigned_short() - directedEnergyTargetEnergyDepositionRecordCount = inputStream.read_unsigned_short() - for idx in range(0, beamAntennaPatternRecordCount): - element = null() - element.parse(inputStream) - self.beamAntennaParameters.append(element) - - for idx in range(0, directedEnergyTargetEnergyDepositionRecordCount): - element = null() - element.parse(inputStream) - self.directedEnergyTargetEnergyDepositionRecordList.append(element) - - class Expendable: """Section 6.2.36 @@ -3182,34 +3037,6 @@ def parse(self, inputStream): self.entityNumber = inputStream.read_unsigned_short() -class DirectedEnergyTargetEnergyDeposition: - """Section 6.2.20.4 - - DE energy deposition properties for a target entity. - """ - - def __init__(self, - targetEntityID: "EntityIdentifier | None" = None, - peakIrradiance: float32 = 0.0): # in W/m^2 - self.targetEntityID = targetEntityID or EntityID() - """Unique ID of the target entity.""" - self.padding: uint16 = 0 - self.peakIrradiance = peakIrradiance - """Peak irrandiance""" - - def serialize(self, outputStream): - """serialize the class""" - self.targetEntityID.serialize(outputStream) - outputStream.write_unsigned_short(self.padding) - outputStream.write_float(self.peakIrradiance) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.targetEntityID.parse(inputStream) - self.padding = inputStream.read_unsigned_short() - self.peakIrradiance = inputStream.read_float() - - class EntityID: """more laconically named EntityIdentifier""" diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 2d309ee..119d5a9 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -784,18 +784,18 @@ def __init__( self.padding2: uint16 = 0 def serialize(self, outputStream: DataOutputStream) -> None: - outputStream.write_unsigned_int(self.recordType) - outputStream.write_unsigned_short(self.recordLength) - outputStream.write_unsigned_short(self.padding) + outputStream.write_uint32(self.recordType) + outputStream.write_uint16(self.recordLength) + outputStream.write_uint16(self.padding) self.damageLocation.serialize(outputStream) - outputStream.write_float(self.damageDiameter) - outputStream.write_float(self.temperature) - outputStream.write_unsigned_byte(self.componentIdentification) - outputStream.write_unsigned_byte(self.componentDamageStatus) - outputStream.write_unsigned_byte(self.componentVisualDamageStatus) - outputStream.write_unsigned_byte(self.componentVisualSmokeColor) + outputStream.write_float32(self.damageDiameter) + outputStream.write_float32(self.temperature) + outputStream.write_uint8(self.componentIdentification) + outputStream.write_uint8(self.componentDamageStatus) + outputStream.write_uint8(self.componentVisualDamageStatus) + outputStream.write_uint8(self.componentVisualSmokeColor) self.fireEventID.serialize(outputStream) - outputStream.write_unsigned_short(self.padding2) + outputStream.write_uint16(self.padding2) def parse(self, inputStream: DataInputStream, @@ -805,14 +805,182 @@ def parse(self, assert isinstance(bytelength, int) self.padding = inputStream.read_unsigned_short() self.damageLocation.parse(inputStream) - self.damageDiameter = inputStream.read_float() - self.temperature = inputStream.read_float() - self.componentIdentification = inputStream.read_unsigned_byte() - self.componentDamageStatus = inputStream.read_unsigned_byte() - self.componentVisualDamageStatus = inputStream.read_unsigned_byte() - self.componentVisualSmokeColor = inputStream.read_unsigned_byte() + self.damageDiameter = inputStream.read_float32() + self.temperature = inputStream.read_float32() + self.componentIdentification = inputStream.read_uint8() + self.componentDamageStatus = inputStream.read_uint8() + self.componentVisualDamageStatus = inputStream.read_uint8() + self.componentVisualSmokeColor = inputStream.read_uint8() self.fireEventID.parse(inputStream) - self.padding2 = inputStream.read_unsigned_short() + self.padding2 = inputStream.read_uint16() + + +class DirectedEnergyAreaAimpoint(base.VariableRecord): + """6.2.20.2 DE Area Aimpoint record + + Targeting information when the target of the directed energy weapon is an + area. The area may or may not be associated with one or more target + entities. + """ + recordType: enum32 = 4001 # [UID 66] + + def __init__(self, + recordLength: uint16 = 0, + beamAntennaParameters: list | None = None, + directedEnergyTargetEnergyDepositions: list | None += None): + self.recordLength = recordLength + self.padding: uint16 = 0 + self.beamAntennaParameters = beamAntennaParameters or [] + self.directedEnergyTargetEnergyDepositionRecordList = directedEnergyTargetEnergyDepositions or [] + + @property + def beamAntennaPatternRecordCount(self) -> uint16: + return len(self.beamAntennaParameters) + + @property + def directedEnergyTargetEnergyDepositionRecordCount(self) -> uint16: + return len(self.directedEnergyTargetEnergyDepositionRecordList) + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_uint32(self.recordType) + outputStream.write_uint16(self.recordLength) + outputStream.write_uint16(self.padding) + outputStream.write_uint16(self.beamAntennaPatternRecordCount) + outputStream.write_uint16( + self.directedEnergyTargetEnergyDepositionRecordCount + ) + for anObj in self.beamAntennaParameters: + anObj.serialize(outputStream) + + for anObj in self.directedEnergyTargetEnergyDepositionRecordList: + anObj.serialize(outputStream) + + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = 0) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + assert isinstance(bytelength, int) + recordLength = inputStream.read_uint16() + self.padding = inputStream.read_uint16() + beamAntennaPatternRecordCount = inputStream.read_uint16() + directedEnergyTargetEnergyDepositionRecordCount = inputStream.read_uint16() + for _ in range(0, beamAntennaPatternRecordCount): + element = null() + element.parse(inputStream) + self.beamAntennaParameters.append(element) + + for idx in range(0, directedEnergyTargetEnergyDepositionRecordCount): + element = null() + element.parse(inputStream) + self.directedEnergyTargetEnergyDepositionRecordList.append(element) + + +class DirectedEnergyPrecisionAimpoint(base.VariableRecord): + """6.2.20.3 DE Precision Aimpoint record + + Targeting information when the target of the directed energy weapon is not + an area but a specific target entity. Use of this record assumes that the DE + weapon would not fire unless a target is known and is currently tracked. + """ + recordType: enum32 = 4000 + recordLength: uint16 = 88 + + def __init__(self, + targetSpotLocation: "Vector3Double | None" = None, + targetSpotEntityLocation: "Vector3Float | None" = None, + targetSpotVelocity: "Vector3Float | None" = None, # in m/s + targetSpotAcceleration: "Vector3Float | None" = None, # in m/s^2 + targetEntityID: "EntityID | None" = None, + targetComponentID: enum8 = 0, # [UID 314] + beamSpotType: enum8 = 0, # [UID 311] + beamSpotCrossSectionSemiMajorAxis: float32 = 0.0, # in meters + beamSpotCrossSectionSemiMinorAxis: float32 = 0.0, # in meters + beamSpotCrossSectionOrientationAngle: float32 = 0.0, # in radians + peakIrradiance: float32 = 0.0): # in W/m^2 + self.padding: uint16 = 0 + self.targetSpotLocation = targetSpotLocation or Vector3Double() + self.targetSpotEntityLocation = targetSpotEntityLocation or Vector3Float( + ) + self.targetSpotVelocity = targetSpotVelocity or Vector3Float() + self.targetSpotAcceleration = targetSpotAcceleration or Vector3Float() + self.targetEntityID = targetEntityID or EntityID() + self.targetComponentID = targetComponentID + self.beamSpotType = beamSpotType + self.beamSpotCrossSectionSemiMajorAxis = beamSpotCrossSectionSemiMajorAxis + self.beamSpotCrossSectionSemiMinorAxis = beamSpotCrossSectionSemiMinorAxis + self.beamSpotCrossSectionOrientationAngle = beamSpotCrossSectionOrientationAngle + self.peakIrradiance = peakIrradiance + self.padding2: uint32 = 0 + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_uint32(self.recordType) + outputStream.write_uint16(self.recordLength) + outputStream.write_uint16(self.padding) + self.targetSpotLocation.serialize(outputStream) + self.targetSpotEntityLocation.serialize(outputStream) + self.targetSpotVelocity.serialize(outputStream) + self.targetSpotAcceleration.serialize(outputStream) + self.targetEntityID.serialize(outputStream) + outputStream.write_uint8(self.targetComponentID) + outputStream.write_uint8(self.beamSpotType) + outputStream.write_float32(self.beamSpotCrossSectionSemiMajorAxis) + outputStream.write_float32(self.beamSpotCrossSectionSemiMinorAxis) + outputStream.write_float32(self.beamSpotCrossSectionOrientationAngle) + outputStream.write_float32(self.peakIrradiance) + outputStream.write_uint32(self.padding2) + + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = 0) -> None: + """recordType and recordLength are assumed to have been read before + this method is called. + """ + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + assert isinstance(bytelength, int) + self.padding = inputStream.read_uint16() + self.targetSpotLocation.parse(inputStream) + self.targetSpotEntityLocation.parse(inputStream) + self.targetSpotVelocity.parse(inputStream) + self.targetSpotAcceleration.parse(inputStream) + self.targetEntityID.parse(inputStream) + self.targetComponentID = inputStream.read_uint8() + self.beamSpotType = inputStream.read_uint8() + self.beamSpotCrossSectionSemiMajorAxis = inputStream.read_float32() + self.beamSpotCrossSectionSemiMinorAxis = inputStream.read_float32() + self.beamSpotCrossSectionOrientationAngle = inputStream.read_float32() + self.peakIrradiance = inputStream.read_float32() + self.padding2 = inputStream.read_uint32() + + +class DirectedEnergyTargetEnergyDeposition(base.Record): + """6.2.20.4 DE Target Energy Deposition record + + DE energy deposition properties for a target entity. + """ + + def __init__(self, + targetEntityID: "EntityIdentifier | None" = None, + peakIrradiance: float32 = 0.0): # in W/m^2 + self.targetEntityID = targetEntityID or EntityID() + """Unique ID of the target entity.""" + self.padding: uint16 = 0 + self.peakIrradiance = peakIrradiance + """Peak irrandiance""" + + def serialize(self, outputStream): + """serialize the class""" + self.targetEntityID.serialize(outputStream) + outputStream.write_uint16(self.padding) + outputStream.write_float32(self.peakIrradiance) + + def parse(self, inputStream): + """Parse a message. This may recursively call embedded objects.""" + self.targetEntityID.parse(inputStream) + self.padding = inputStream.read_uint16() + self.peakIrradiance = inputStream.read_float32() __variableRecordClasses: dict[int, type[base.VariableRecord]] = { From 4ccd7bdfd67ca749dd691e80ffec96c4aa3086a1 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 01:16:26 +0000 Subject: [PATCH 12/37] refactor: migrate WorldCoordinates to record namespace; clean up forward references --- opendis/dis7.py | 195 ++++++++++++++++--------------------- opendis/record/__init__.py | 33 ++++++- 2 files changed, 115 insertions(+), 113 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index da864eb..9b3206f 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -12,6 +12,7 @@ UnknownAntennaPattern, EulerAngles, Vector3Float, + WorldCoordinates, EventIdentifier, SimulationAddress, BeamAntennaPattern, @@ -930,12 +931,12 @@ class Association: def __init__(self, associationType: enum8 = 0, # [UID 330] associatedEntityID: "EntityID | None" = None, - associatedLocation: "Vector3Double | None" = None): + associatedLocation: WorldCoordinates | None = None): self.associationType = associationType self.padding4: uint8 = 0 self.associatedEntityID = associatedEntityID or EntityID() """identity of associated entity. If none, NO_SPECIFIC_ENTITY""" - self.associatedLocation = associatedLocation or Vector3Double() + self.associatedLocation = associatedLocation or WorldCoordinates() """location, in world coordinates""" def serialize(self, outputStream): @@ -1003,9 +1004,9 @@ class AntennaLocation: """ def __init__(self, - antennaLocation: "Vector3Double | None" = None, + antennaLocation: WorldCoordinates | None = None, relativeAntennaLocation: "Vector3Float | None" = None): - self.antennaLocation = antennaLocation or Vector3Double() + self.antennaLocation = antennaLocation or WorldCoordinates() """Location of the radiating portion of the antenna in world coordinates""" self.relativeAntennaLocation = relativeAntennaLocation or Vector3Float( ) @@ -1691,8 +1692,8 @@ def __init__(self, segmentModification: enum8 = 0, # [UID 241] generalSegmentAppearance: struct16 = 0, # [UID 229] specificSegmentAppearance: struct32 = 0, # TODO: find reference - segmentLocation: "Vector3Double | None" = None, - segmentOrientation: "EulerAngles | None" = None, + segmentLocation: WorldCoordinates | None = None, + segmentOrientation: EulerAngles | None = None, segmentLength: float32 = 0.0, # in meters segmentWidth: float32 = 0.0, # in meters segmentHeight: float32 = 0.0, # in meters @@ -1705,7 +1706,7 @@ def __init__(self, """general dynamic appearance attributes of the segment. This record shall be defined as a 16-bit record of enumerations. The values defined for this record are included in Section 12 of SISO-REF-010.""" self.specificSegmentAppearance = specificSegmentAppearance """This field shall specify specific dynamic appearance attributes of the segment. This record shall be defined as a 32-bit record of enumerations.""" - self.segmentLocation = segmentLocation or Vector3Double() + self.segmentLocation = segmentLocation or WorldCoordinates() """This field shall specify the location of the linear segment in the simulated world and shall be represented by a World Coordinates record""" self.segmentOrientation = segmentOrientation or EulerAngles() """orientation of the linear segment about the segment location and shall be represented by a Euler Angles record""" @@ -2291,17 +2292,17 @@ class LaunchedMunitionRecord: """ def __init__(self, - fireEventID: "EventIdentifier | None" = None, - firingEntityID: "EventIdentifier | None" = None, - targetEntityID: "EventIdentifier | None" = None, - targetLocation: "Vector3Double | None" = None): + fireEventID: EventIdentifier | None = None, + firingEntityID: EventIdentifier | None = None, + targetEntityID: EventIdentifier | None = None, + targetLocation: WorldCoordinates | None = None): self.fireEventID = fireEventID or EventIdentifier() self.padding: uint16 = 0 self.firingEntityID = firingEntityID or EventIdentifier() self.padding2: uint16 = 0 self.targetEntityID = targetEntityID or EventIdentifier() self.padding3: uint16 = 0 - self.targetLocation = targetLocation or Vector3Double() + self.targetLocation = targetLocation or WorldCoordinates() def serialize(self, outputStream): """serialize the class""" @@ -3234,34 +3235,6 @@ def parse(self, inputStream): self.padding2 = inputStream.read_unsigned_byte() -class Vector3Double: - """Section 6.2.97 - - Three double precision floating point values, x, y, and z. - Used for world coordinates. - """ - - def __init__(self, x: float32 = 0.0, y: float32 = 0.0, z: float32 = 0.0): - self.x = x - """X value""" - self.y = y - """y Value""" - self.z = z - """Z value""" - - def serialize(self, outputStream): - """serialize the class""" - outputStream.write_double(self.x) - outputStream.write_double(self.y) - outputStream.write_double(self.z) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.x = inputStream.read_double() - self.y = inputStream.read_double() - self.z = inputStream.read_double() - - class GridAxis: """Section 6.2.41 @@ -3820,18 +3793,18 @@ class EntityStateUpdatePdu(EntityInformationFamilyPdu): def __init__(self, entityID=None, - entityLinearVelocity: "Vector3Float | None" = None, - entityLocation: "Vector3Double | None" = None, - entityOrientation: "EulerAngles | None" = None, + entityLinearVelocity: Vector3Float | None = None, + entityLocation: WorldCoordinates | None = None, + entityOrientation: EulerAngles | None = None, entityAppearance: struct32 = 0, # [UID 31-43] - variableParameters: list["VariableParameter"] | None = None): + variableParameters: list[VariableParameter] | None = None): super(EntityStateUpdatePdu, self).__init__() self.entityID = entityID or EntityID() """This field shall identify the entity issuing the PDU, and shall be represented by an Entity Identifier record (see 6.2.28).""" self.padding1: uint8 = 0 self.entityLinearVelocity = entityLinearVelocity or Vector3Float() """This field shall specify an entitys linear velocity. The coordinate system for an entitys linear velocity depends on the dead reckoning algorithm used. This field shall be represented by a Linear Velocity Vector record [see 6.2.95 item c)]).""" - self.entityLocation = entityLocation or Vector3Double() + self.entityLocation = entityLocation or WorldCoordinates() """This field shall specify an entitys physical location in the simulated world and shall be represented by a World Coordinates record (see 6.2.97).""" self.entityOrientation = entityOrientation or EulerAngles() """This field shall specify an entitys orientation and shall be represented by an Euler Angles record (see 6.2.33).""" @@ -4736,17 +4709,17 @@ class DesignatorPdu(DistributedEmissionsFamilyPdu): pduType: enum8 = 24 # [UID 4] def __init__(self, - designatingEntityID: "EntityID | None" = None, + designatingEntityID: EntityID | None = None, codeName: enum16 = 0, # [UID 80] - designatedEntityID: "EntityID | None" = None, + designatedEntityID: EntityID | None = None, designatorCode: enum16 = 0, # [UID 81] designatorPower: float32 = 0.0, # in W designatorWavelength: float32 = 0.0, # in microns - designatorSpotWrtDesignated: "Vector3Float | None" = None, - designatorSpotLocation: "Vector3Double | None" = None, + designatorSpotWrtDesignated: Vector3Float | None = None, + designatorSpotLocation: WorldCoordinates | None = None, # Dead Reckoning Parameters deadReckoningAlgorithm: enum8 = 0, # [UID 44] - entityLinearAcceleration: "Vector3Float | None" = None): + entityLinearAcceleration: Vector3Float | None = None): super(DesignatorPdu, self).__init__() self.designatingEntityID = designatingEntityID or EntityID() """ID of the entity designating""" @@ -4763,7 +4736,7 @@ def __init__(self, self.designatorSpotWrtDesignated = designatorSpotWrtDesignated or Vector3Float( ) """designator spot wrt the designated entity""" - self.designatorSpotLocation = designatorSpotLocation or Vector3Double() + self.designatorSpotLocation = designatorSpotLocation or WorldCoordinates() """designator spot wrt the designated entity""" self.deadReckoningAlgorithm = deadReckoningAlgorithm """Dead reckoning algorithm""" @@ -4856,18 +4829,18 @@ class EntityStatePdu(EntityInformationFamilyPdu): pduType: enum8 = 1 # [UID 4] def __init__(self, - entityID: "EntityID | None" = None, + entityID: EntityID | None = None, forceId: enum8 = 0, # [UID 6] - entityType: "EntityType | None" = None, - alternativeEntityType: "EntityType | None" = None, - entityLinearVelocity: "Vector3Float | None" = None, - entityLocation: "Vector3Double | None" = None, - entityOrientation: "EulerAngles | None" = None, + entityType: EntityType | None = None, + alternativeEntityType: EntityType | None = None, + entityLinearVelocity: Vector3Float | None = None, + entityLocation: WorldCoordinates | None = None, + entityOrientation: EulerAngles | None = None, entityAppearance: uint32 = 0, # [UID 31-43] - deadReckoningParameters: "DeadReckoningParameters | None" = None, - marking: "EntityMarking | None" = None, + deadReckoningParameters: DeadReckoningParameters | None = None, + marking: EntityMarking | None = None, capabilities: uint32 = 0, # [UID 55] - variableParameters: list["VariableParameter"] | None = None): + variableParameters: list[VariableParameter] | None = None): super(EntityStatePdu, self).__init__() self.entityID = entityID or EntityID() """Unique ID for an entity that is tied to this state information""" @@ -4878,7 +4851,7 @@ def __init__(self, self.alternativeEntityType = alternativeEntityType or EntityType() self.entityLinearVelocity = entityLinearVelocity or Vector3Float() """Describes the speed of the entity in the world""" - self.entityLocation = entityLocation or Vector3Double() + self.entityLocation = entityLocation or WorldCoordinates() """describes the location of the entity in the world""" self.entityOrientation = entityOrientation or EulerAngles() """describes the orientation of the entity, in euler angles""" @@ -5005,16 +4978,16 @@ class TransmitterPdu(RadioCommunicationsFamilyPdu): def __init__(self, radioReferenceID: "EntityID | ObjectIdentifier | None" = None, radioNumber: uint16 = 0, - radioEntityType: "EntityType | None" = None, + radioEntityType: EntityType | None = None, transmitState: enum8 = 0, # [UID 164] inputSource: enum8 = 0, # [UID 165] - antennaLocation: "Vector3Double | None" = None, - relativeAntennaLocation: "Vector3Float | None" = None, + antennaLocation: WorldCoordinates | None = None, + relativeAntennaLocation: Vector3Float | None = None, antennaPatternType: enum16 = 0, # [UID 167] frequency: uint64 = 0, # in Hz transmitFrequencyBandwidth: float32 = 0.0, # in Hz power: float32 = 0.0, # in decibel-milliwatts - modulationType: "ModulationType | None" = None, + modulationType: ModulationType | None = None, cryptoSystem: enum16 = 0, # [UID 166] cryptoKeyId: struct16 = 0, # See Table 175 modulationParameters: ModulationParametersRecord | None = None, @@ -5028,7 +5001,7 @@ def __init__(self, self.radioEntityType = radioEntityType or EntityType() # TODO: validation self.transmitState = transmitState self.inputSource = inputSource - self.antennaLocation = antennaLocation or Vector3Double() + self.antennaLocation = antennaLocation or WorldCoordinates() self.relativeAntennaLocation = relativeAntennaLocation or Vector3Float( ) self.antennaPatternType = antennaPatternType @@ -5571,17 +5544,17 @@ class PointObjectStatePdu(SyntheticEnvironmentFamilyPdu): pduType: enum8 = 43 # [UID 4] def __init__(self, - objectID: "EntityID | None" = None, - referencedObjectID: "EntityID | None" = None, + objectID: EntityID | None = None, + referencedObjectID: EntityID | None = None, updateNumber: uint16 = 0, forceID: enum8 = 0, # [UID 6] modifications : enum8 = 0, # [UID 240] - objectType: "ObjectType | None" = None, - objectLocation: "Vector3Double | None" = None, - objectOrientation: "EulerAngles | None" = None, + objectType: ObjectType | None = None, + objectLocation: WorldCoordinates | None = None, + objectOrientation: EulerAngles | None = None, objectAppearance: struct32 | struct16 = 0, # [UID 229] - requesterID: "SimulationAddress | None" = None, - receivingID: "SimulationAddress | None" = None): + requesterID: SimulationAddress | None = None, + receivingID: SimulationAddress | None = None): super(PointObjectStatePdu, self).__init__() # TODO: Validate ObjectID? self.objectID = objectID or EntityID() @@ -5596,7 +5569,7 @@ def __init__(self, """modifications""" self.objectType = objectType or ObjectType() """Object type""" - self.objectLocation = objectLocation or Vector3Double() + self.objectLocation = objectLocation or WorldCoordinates() """Object location""" self.objectOrientation = objectOrientation or EulerAngles() """Object orientation""" @@ -6016,17 +5989,17 @@ class ArealObjectStatePdu(SyntheticEnvironmentFamilyPdu): pduType: enum8 = 45 # [UID 4] def __init__(self, - objectID: "EntityID | None" = None, - referencedObjectID: "EntityID | None" = None, + objectID: EntityID | None = None, + referencedObjectID: EntityID | None = None, updateNumber: uint16 = 0, forceId: enum8 = 0, # [UID 6] modifications: enum8 = 0, # [UID 242] - objectType: "ObjectType | None" = None, + objectType: ObjectType | None = None, specificObjectAppearance: struct32 = 0, generalObjectAppearance: struct16 = 0, # [UID 229] - requesterID: "SimulationAddress | None" = None, - receivingID: "SimulationAddress | None" = None, - objectLocation: list["Vector3Double"] | None = None): + requesterID: SimulationAddress | None = None, + receivingID: SimulationAddress | None = None, + objectLocation: list[WorldCoordinates] | None = None): super(ArealObjectStatePdu, self).__init__() # TODO: validate object ID? self.objectID = objectID or EntityID() @@ -6080,7 +6053,7 @@ def parse(self, inputStream): self.requesterID.parse(inputStream) self.receivingID.parse(inputStream) for idx in range(0, numberOfPoints): - element = Vector3Double() + element = WorldCoordinates() element.parse(inputStream) self.objectLocation.append(element) @@ -6159,19 +6132,19 @@ def __init__(self, minefieldID: "MinefieldIdentifier | None" = None, minefieldSequence: uint16 = 0, forceID: enum8 = 0, # [UID 6] - minefieldType: "EntityType | None" = None, - minefieldLocation: "Vector3Double | None" = None, - minefieldOrientation: "EulerAngles | None" = None, + minefieldType: EntityType | None = None, + minefieldLocation: WorldCoordinates | None = None, + minefieldOrientation: EulerAngles | None = None, appearance: struct16 = 0, # [UID 190] protocolMode: struct16 = 0, # See 6.2.69 - perimeterPoints: list["Vector2Float"] | None = None, - mineTypes: list["EntityType"] | None = None): + perimeterPoints: list[Vector2Float] | None = None, + mineTypes: list[EntityType] | None = None): super(MinefieldStatePdu, self).__init__() self.minefieldID = minefieldID or MinefieldIdentifier() self.minefieldSequence = minefieldSequence self.forceID = forceID self.minefieldType = minefieldType or EntityType() - self.minefieldLocation = minefieldLocation or Vector3Double() + self.minefieldLocation = minefieldLocation or WorldCoordinates() """location of center of minefield in world coords""" self.minefieldOrientation = minefieldOrientation or EulerAngles() self.appearance = appearance @@ -6486,21 +6459,21 @@ class DetonationPdu(WarfareFamilyPdu): pduType: enum8 = 3 # [UID 4] def __init__(self, - explodingEntityID: "EntityID | None" = None, - eventID: "EventIdentifier | None" = None, - velocity: "Vector3Float | None" = None, - location: "Vector3Double | None" = None, - descriptor: "MunitionDescriptor | None" = None, - locationInEntityCoordinates: "Vector3Float | None" = None, + explodingEntityID: EntityID | None = None, + eventID: EventIdentifier | None = None, + velocity: Vector3Float | None = None, + location: WorldCoordinates | None = None, + descriptor: MunitionDescriptor | None = None, + locationInEntityCoordinates: Vector3Float | None = None, detonationResult: enum8 = 0, # [UID 62] - variableParameters: list["VariableParameter"] | None = None): + variableParameters: list[VariableParameter] | None = None): super(DetonationPdu, self).__init__() self.explodingEntityID = explodingEntityID or EntityID() """ID of the expendable entity, Section 7.3.3""" self.eventID = eventID or EventIdentifier() self.velocity = velocity or Vector3Float() """velocity of the munition immediately before detonation/impact, Section 7.3.3""" - self.location = location or Vector3Double( + self.location = location or WorldCoordinates( ) """location of the munition detonation, the expendable detonation, Section 7.3.3""" self.descriptor = descriptor or MunitionDescriptor() @@ -6761,12 +6734,12 @@ class FirePdu(WarfareFamilyPdu): pduType: enum8 = 2 # [UID 4] def __init__(self, - munitionExpendableID: "EntityID | None" = None, - eventID: "EventIdentifier | None" = None, + munitionExpendableID: EntityID | None = None, + eventID: EventIdentifier | None = None, fireMissionIndex: uint32 = 0, - location: "Vector3Double | None" = None, - descriptor: "MunitionDescriptor | None" = None, - velocity: "Vector3Float | None" = None, + location: WorldCoordinates | None = None, + descriptor: MunitionDescriptor | None = None, + velocity: Vector3Float | None = None, range_: float32 = 0.0): # in meters super(FirePdu, self).__init__() self.munitionExpendableID = munitionExpendableID or EntityID() @@ -6775,7 +6748,7 @@ def __init__(self, """This field shall contain an identification generated by the firing entity to associate related firing and detonation events. This field shall be represented by an Event Identifier record (see 6.2.34).""" self.fireMissionIndex = fireMissionIndex """This field shall identify the fire mission (see 5.4.3.3). This field shall be representedby a 32-bit unsigned integer.""" - self.location = location or Vector3Double( + self.location = location or WorldCoordinates( ) """This field shall specify the location, in world coordinates, from which the munition was launched, and shall be represented by a World Coordinates record (see 6.2.97).""" self.descriptor = descriptor or MunitionDescriptor() @@ -7487,15 +7460,15 @@ def __init__(self, aggregateID: "AggregateIdentifier | None" = None, aggregateType: "AggregateType | None" = None, formation: enum32 = 0, aggregateMarking: "AggregateMarking | None" = None, - dimensions: "Vector3Float | None" = None, - orientation: "EulerAngles | None" = None, - centerOfMass: "Vector3Float | None" = None, - velocity: "Vector3Double | None" = None, - aggregateIDs: list["AggregateIdentifier"] | None = None, - entityIDs: list["EntityIdentifier"] | None = None, - silentAggregateSystems: list["SilentAggregateSystem"] | None = None, - silentEntitySystems: list["SilentEntitySystem"] | None = None, - variableDatumRecords: list["VariableDatum"] | None = None): + dimensions: Vector3Float | None = None, + orientation: EulerAngles | None = None, + centerOfMass: Vector3Float | None = None, + velocity: WorldCoordinates | None = None, + aggregateIDs: list[AggregateIdentifier] | None = None, + entityIDs: list[EntityIdentifier] | None = None, + silentAggregateSystems: list[SilentAggregateSystem] | None = None, + silentEntitySystems: list[SilentEntitySystem] | None = None, + variableDatumRecords: list[VariableDatum] | None = None): super(AggregateStatePdu, self).__init__() """Identifier of the aggregate issuing the PDU""" self.aggregateID = aggregateID or AggregateIdentifier() @@ -7512,7 +7485,7 @@ def __init__(self, aggregateID: "AggregateIdentifier | None" = None, self.orientation = orientation or EulerAngles() self.centerOfMass = centerOfMass or Vector3Float() """Aggregates linear velocity. The coordinate system is dependent on the dead reckoning algorithm""" - self.velocity = velocity or Vector3Double() + self.velocity = velocity or WorldCoordinates() """Identify subaggregates that are transmitting Aggregate State PDUs""" self.aggregateIDs = aggregateIDs or [] """Constituent entities transmitting Entity State PDUs""" diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 119d5a9..c573fb7 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -16,6 +16,7 @@ bf_int, bf_uint, float32, + float64, struct8, uint8, uint16, @@ -54,6 +55,34 @@ def parse(self, inputStream: DataInputStream) -> None: self.z = inputStream.read_float() +class WorldCoordinates(base.Record): + """6.2.98 World Coordinates record + + Location of the origin of the entity's or object's coordinate system, + target locations, detonation locations, and other points shall be specified + by a set of three coordinates: X, Y, and Z, represented by 64-bit floating + point numbers. + """ + + def __init__(self, x: float64 = 0.0, y: float64 = 0.0, z: float64 = 0.0): + self.x = x + self.y = y + self.z = z + + def marshalledSize(self) -> int: + return 24 + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_float64(self.x) + outputStream.write_float64(self.y) + outputStream.write_float64(self.z) + + def parse(self, inputStream: DataInputStream) -> None: + self.x = inputStream.read_float64() + self.y = inputStream.read_float64() + self.z = inputStream.read_float64() + + class EulerAngles(base.Record): """6.2.32 Euler Angles record @@ -764,14 +793,14 @@ class DirectedEnergyDamage(DamageDescriptionRecord): def __init__( self, - damageLocation: "Vector3Float | None" = None, + damageLocation: Vector3Float | None = None, damageDiameter: float32 = 0.0, # in metres temperature: float32 = -273.15, # in degrees Celsius componentIdentification: enum8 = 0, # [UID 314] componentDamageStatus: enum8 = 0, # [UID 315] componentVisualDamageStatus: struct8 = 0, # [UID 317] componentVisualSmokeColor: enum8 = 0, # [UID 316] - fireEventID: "EventIdentifier | None" = None): + fireEventID: EventIdentifier | None = None): self.padding: uint16 = 0 self.damageLocation = damageLocation or Vector3Float() self.damageDiameter = damageDiameter From 1a38e109c01bbb9c56ec82ff9ab5807619066cb8 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 01:18:28 +0000 Subject: [PATCH 13/37] refactor: clean up forward references --- opendis/record/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index c573fb7..43ab06e 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -917,10 +917,10 @@ class DirectedEnergyPrecisionAimpoint(base.VariableRecord): recordLength: uint16 = 88 def __init__(self, - targetSpotLocation: "Vector3Double | None" = None, - targetSpotEntityLocation: "Vector3Float | None" = None, - targetSpotVelocity: "Vector3Float | None" = None, # in m/s - targetSpotAcceleration: "Vector3Float | None" = None, # in m/s^2 + targetSpotLocation: WorldCoordinates | None = None, + targetSpotEntityLocation: Vector3Float | None = None, + targetSpotVelocity: Vector3Float | None = None, # in m/s + targetSpotAcceleration: Vector3Float | None = None, # in m/s^2 targetEntityID: "EntityID | None" = None, targetComponentID: enum8 = 0, # [UID 314] beamSpotType: enum8 = 0, # [UID 311] @@ -929,7 +929,7 @@ def __init__(self, beamSpotCrossSectionOrientationAngle: float32 = 0.0, # in radians peakIrradiance: float32 = 0.0): # in W/m^2 self.padding: uint16 = 0 - self.targetSpotLocation = targetSpotLocation or Vector3Double() + self.targetSpotLocation = targetSpotLocation or WorldCoordinates() self.targetSpotEntityLocation = targetSpotEntityLocation or Vector3Float( ) self.targetSpotVelocity = targetSpotVelocity or Vector3Float() From 0f6582b7fdc750671ce26b81078f82828b36ebb9 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 01:21:46 +0000 Subject: [PATCH 14/37] refactor: migrate EntityID and EntityIdentifier to record namespace --- opendis/dis7.py | 53 ++------------------------------------ opendis/record/__init__.py | 44 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 51 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 9b3206f..95c01b8 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -13,6 +13,8 @@ EulerAngles, Vector3Float, WorldCoordinates, + EntityID, + EntityIdentifier, EventIdentifier, SimulationAddress, BeamAntennaPattern, @@ -3012,57 +3014,6 @@ def parse(self, inputStream): self.maximumQuantityReloadTime = inputStream.read_unsigned_int() -class EntityIdentifier: - """Section 6.2.28 - - Entity Identifier. Unique ID for entities in the world. Consists of a - simulation address and a entity number. - """ - - def __init__(self, - simulationAddress: "SimulationAddress | None" = None, - entityNumber: uint16 = 0): - self.simulationAddress = simulationAddress or SimulationAddress() - """Site and application IDs""" - self.entityNumber = entityNumber - """Entity number""" - - def serialize(self, outputStream): - """serialize the class""" - self.simulationAddress.serialize(outputStream) - outputStream.write_unsigned_short(self.entityNumber) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - self.simulationAddress.parse(inputStream) - self.entityNumber = inputStream.read_unsigned_short() - - -class EntityID: - """more laconically named EntityIdentifier""" - - def __init__(self, siteID=0, applicationID=0, entityID=0): - self.siteID = siteID - """Site ID""" - self.applicationID = applicationID - """application number ID""" - self.entityID = entityID - """Entity number ID""" - - def serialize(self, outputStream): - """serialize the class""" - outputStream.write_unsigned_short(self.siteID) - outputStream.write_unsigned_short(self.applicationID) - outputStream.write_unsigned_short(self.entityID) - - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" - - self.siteID = inputStream.read_unsigned_short() - self.applicationID = inputStream.read_unsigned_short() - self.entityID = inputStream.read_unsigned_short() - - class EngineFuelReload: """For each type or location of engine fuel, this record specifies the type, location, fuel measurement units, and reload quantity and maximum quantity. Section 6.2.25.""" diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 43ab06e..c5740e8 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -83,6 +83,50 @@ def parse(self, inputStream: DataInputStream) -> None: self.z = inputStream.read_float64() +class EntityID: + """more laconically named EntityIdentifier""" + + def __init__(self, siteID=0, applicationID=0, entityID=0): + self.siteID = siteID + self.applicationID = applicationID + self.entityID = entityID + + def serialize(self, outputStream: DataOutputStream) -> None: + """serialize the class""" + outputStream.write_unsigned_short(self.siteID) + outputStream.write_unsigned_short(self.applicationID) + outputStream.write_unsigned_short(self.entityID) + + def parse(self, inputStream: DataInputStream) -> None: + """Parse a message. This may recursively call embedded objects.""" + + self.siteID = inputStream.read_unsigned_short() + self.applicationID = inputStream.read_unsigned_short() + self.entityID = inputStream.read_unsigned_short() + + +class EntityIdentifier: + """Section 6.2.28 + + Entity Identifier. Unique ID for entities in the world. Consists of a + simulation address and a entity number. + """ + + def __init__(self, + simulationAddress: "SimulationAddress | None" = None, + entityNumber: uint16 = 0): + self.simulationAddress = simulationAddress or SimulationAddress() + self.entityNumber = entityNumber + + def serialize(self, outputStream: DataOutputStream) -> None: + self.simulationAddress.serialize(outputStream) + outputStream.write_uint16(self.entityNumber) + + def parse(self, inputStream: DataInputStream) -> None: + self.simulationAddress.parse(inputStream) + self.entityNumber = inputStream.read_uint16() + + class EulerAngles(base.Record): """6.2.32 Euler Angles record From e3b95d15b04d29261457e3ea49bfc3d764e0af38 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 01:53:03 +0000 Subject: [PATCH 15/37] refactor: replace all use of EntityID with EntityIdentifier --- opendis/dis7.py | 236 +++++++++++----------- tests/test_ElectromageneticEmissionPdu.py | 12 +- tests/test_EntityStatePdu.py | 6 +- tests/test_SignalPdu.py | 6 +- tests/test_TransmitterPdu.py | 6 +- 5 files changed, 134 insertions(+), 132 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 95c01b8..63b225f 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -320,8 +320,8 @@ class DeadReckoningParameters: def __init__(self, deadReckoningAlgorithm: enum8 = 0, # [UID 44] parameters=None, - entityLinearAcceleration: "Vector3Float | None" = None, - entityAngularVelocity: "Vector3Float | None" = None): + entityLinearAcceleration: Vector3Float | None = None, + entityAngularVelocity: Vector3Float | None = None): self.deadReckoningAlgorithm = deadReckoningAlgorithm """Algorithm to use in computing dead reckoning. See EBV doc.""" self.parameters = parameters or [0] * 15 @@ -473,7 +473,7 @@ def __init__(self, """Indicates association status between two entities; 8 bit enum""" self.associationType = associationType """Type of association; 8 bit enum""" - self.entityID = entityID or EntityID() + self.entityID = entityID or EntityIdentifier() """Object ID of entity associated with this entity""" self.ownStationLocation = ownStationLocation """Station location on one's own entity. EBV doc.""" @@ -730,8 +730,10 @@ class OwnershipStatus: Used to convey entity and conflict status information associated with transferring ownership of an entity. """ - def __init__(self, entityId=None, ownershipStatus=0): - self.entityId = entityId or EntityID() + def __init__(self, + entityId: EntityIdentifier | None = None, + ownershipStatus=0): + self.entityId = entityId or EntityIdentifier() """EntityID""" self.ownershipStatus = ownershipStatus """The ownership and/or ownership conflict status of the entity represented by the Entity ID field.""" @@ -932,11 +934,11 @@ class Association: def __init__(self, associationType: enum8 = 0, # [UID 330] - associatedEntityID: "EntityID | None" = None, + associatedEntityID: EntityIdentifier | None = None, associatedLocation: WorldCoordinates | None = None): self.associationType = associationType self.padding4: uint8 = 0 - self.associatedEntityID = associatedEntityID or EntityID() + self.associatedEntityID = associatedEntityID or EntityIdentifier() """identity of associated entity. If none, NO_SPECIFIC_ENTITY""" self.associatedLocation = associatedLocation or WorldCoordinates() """location, in world coordinates""" @@ -1007,7 +1009,7 @@ class AntennaLocation: def __init__(self, antennaLocation: WorldCoordinates | None = None, - relativeAntennaLocation: "Vector3Float | None" = None): + relativeAntennaLocation: Vector3Float | None = None): self.antennaLocation = antennaLocation or WorldCoordinates() """Location of the radiating portion of the antenna in world coordinates""" self.relativeAntennaLocation = relativeAntennaLocation or Vector3Float( @@ -1793,10 +1795,10 @@ class TrackJamData: """ def __init__(self, - entityID: "EntityID | None" = None, + entityID: EntityIdentifier | None = None, emitterNumber: uint8 = 0, beamNumber: uint8 = 0): - self.entityID = entityID or EntityID() + self.entityID = entityID or EntityIdentifier() """the entity tracked or illumated, or an emitter beam targeted with jamming""" self.emitterNumber = emitterNumber """Emitter system associated with the entity""" @@ -1874,8 +1876,8 @@ class SimulationManagementPduHeader: def __init__(self, pduHeader: "PduHeader | None" = None, - originatingID: "SimulationIdentifier | EntityID | None" = None, - receivingID: "SimulationIdentifier | EntityID | None" = None): + originatingID: "SimulationIdentifier | EntityIdentifier | None" = None, + receivingID: "SimulationIdentifier | EntityIdentifier | None" = None): self.pduHeader = pduHeader or PduHeader() """Conventional PDU header""" self.originatingID = originatingID or SimulationIdentifier() @@ -3564,14 +3566,14 @@ class SeparationVP: def __init__(self, reasonForSeparation: enum8 = 0, # [UID 282] preEntityIndicator: enum8 = 0, # [UID 283] - parentEntityID: "EntityID | None" = None, + parentEntityID: EntityIdentifier | None = None, stationLocation: "NamedLocationIdentification | None" = None): self.reasonForSeparation = reasonForSeparation """Reason for separation. EBV""" self.preEntityIndicator = preEntityIndicator """Whether the entity existed prior to separation EBV""" self.padding1: uint8 = 0 - self.parentEntityID = parentEntityID or EntityID() + self.parentEntityID = parentEntityID or EntityIdentifier() """ID of parent""" self.padding2: uint16 = 0 self.stationLocation = stationLocation or NamedLocationIdentification() @@ -3743,14 +3745,14 @@ class EntityStateUpdatePdu(EntityInformationFamilyPdu): pduType: enum8 = 67 # [UID 4] def __init__(self, - entityID=None, + entityID: EntityIdentifier | None = None, entityLinearVelocity: Vector3Float | None = None, entityLocation: WorldCoordinates | None = None, entityOrientation: EulerAngles | None = None, entityAppearance: struct32 = 0, # [UID 31-43] variableParameters: list[VariableParameter] | None = None): super(EntityStateUpdatePdu, self).__init__() - self.entityID = entityID or EntityID() + self.entityID = entityID or EntityIdentifier() """This field shall identify the entity issuing the PDU, and shall be represented by an Entity Identifier record (see 6.2.28).""" self.padding1: uint8 = 0 self.entityLinearVelocity = entityLinearVelocity or Vector3Float() @@ -3806,14 +3808,14 @@ class ServiceRequestPdu(LogisticsFamilyPdu): pduType: enum8 = 5 # [UID 4] def __init__(self, - requestingEntityID: "EntityID | None" = None, - servicingEntityID: "EntityID | None" = None, + requestingEntityID: EntityIdentifier | None = None, + servicingEntityID: EntityIdentifier | None = None, serviceTypeRequested: enum8 = 0, # [UID 63] supplies: list["SupplyQuantity"] | None = None): super(ServiceRequestPdu, self).__init__() - self.requestingEntityID = requestingEntityID or EntityID() + self.requestingEntityID = requestingEntityID or EntityIdentifier() """Entity that is requesting service (see 6.2.28), Section 7.4.2""" - self.servicingEntityID = servicingEntityID or EntityID() + self.servicingEntityID = servicingEntityID or EntityIdentifier() """Entity that is providing the service (see 6.2.28), Section 7.4.2""" self.serviceTypeRequested = serviceTypeRequested """Type of service requested, Section 7.4.2""" @@ -3857,13 +3859,13 @@ class RepairCompletePdu(LogisticsFamilyPdu): pduType: enum8 = 9 # [UID 4] def __init__(self, - receivingEntityID: "EntityID | None" = None, - repairingEntityID: "EntityID | None" = None, + receivingEntityID: EntityIdentifier | None = None, + repairingEntityID: EntityIdentifier | None = None, repair: enum16 = 0): # [UID 64] super(RepairCompletePdu, self).__init__() - self.receivingEntityID = receivingEntityID or EntityID() + self.receivingEntityID = receivingEntityID or EntityIdentifier() """Entity that is receiving service. See 6.2.28""" - self.repairingEntityID = repairingEntityID or EntityID() + self.repairingEntityID = repairingEntityID or EntityIdentifier() """Entity that is supplying. See 6.2.28""" self.repair = repair """Enumeration for type of repair. See 6.2.74""" @@ -3915,17 +3917,17 @@ class CollisionPdu(EntityInformationFamilyPdu): pduType: enum8 = 4 # [UID 4] def __init__(self, - issuingEntityID: "EntityID | None" = None, - collidingEntityID: "EntityID | None" = None, + issuingEntityID: EntityIdentifier | None = None, + collidingEntityID: EntityIdentifier | None = None, eventID: "EventIdentifier | None" = None, collisionType: enum8 = 0, # [UID 189] - velocity: "Vector3Float | None" = None, + velocity: Vector3Float | None = None, mass: float32 = 0.0, # in kg - location: "Vector3Float | None" = None): + location: Vector3Float | None = None): super(CollisionPdu, self).__init__() - self.issuingEntityID = issuingEntityID or EntityID() + self.issuingEntityID = issuingEntityID or EntityIdentifier() """This field shall identify the entity that is issuing the PDU, and shall be represented by an Entity Identifier record (see 6.2.28).""" - self.collidingEntityID = collidingEntityID or EntityID() + self.collidingEntityID = collidingEntityID or EntityIdentifier() """This field shall identify the entity that has collided with the issuing entity (see 5.3.3.4). This field shall be represented by an Entity Identifier record (see 6.2.28).""" self.eventID = eventID or EventIdentifier() """This field shall contain an identification generated by the issuing simulation application to associate related collision events. This field shall be represented by an Event Identifier record (see 6.2.34).""" @@ -3972,13 +3974,13 @@ class RepairResponsePdu(LogisticsFamilyPdu): pduType: enum8 = 10 # [UID 4] def __init__(self, - receivingEntityID: "EntityID | None" = None, - repairingEntityID: "EntityID | None" = None, + receivingEntityID: EntityIdentifier | None = None, + repairingEntityID: EntityIdentifier | None = None, repairResult: enum8 = 0): # [UID 65] super(RepairResponsePdu, self).__init__() - self.receivingEntityID = receivingEntityID or EntityID() + self.receivingEntityID = receivingEntityID or EntityIdentifier() """Entity that requested repairs. See 6.2.28""" - self.repairingEntityID = repairingEntityID or EntityID() + self.repairingEntityID = repairingEntityID or EntityIdentifier() """Entity that is repairing. See 6.2.28""" self.repairResult = repairResult """Result of repair operation""" @@ -4012,12 +4014,12 @@ class SimulationManagementFamilyPdu(Pdu): protocolFamily: enum8 = 5 # [UID 5] def __init__(self, - originatingEntityID: "EntityID | None" = None, - receivingEntityID: "EntityID | None" = None): + originatingEntityID: EntityIdentifier | None = None, + receivingEntityID: EntityIdentifier | None = None): super(SimulationManagementFamilyPdu, self).__init__() - self.originatingEntityID = originatingEntityID or EntityID() + self.originatingEntityID = originatingEntityID or EntityIdentifier() """Entity that is sending message""" - self.receivingEntityID = receivingEntityID or EntityID() + self.receivingEntityID = receivingEntityID or EntityIdentifier() """Entity that is intended to receive message""" def serialize(self, outputStream): @@ -4094,8 +4096,8 @@ class LinearObjectStatePdu(SyntheticEnvironmentFamilyPdu): pduType: enum8 = 44 # [UID 4] def __init__(self, - objectID: "EntityID | None" = None, - referencedObjectID: "EntityID | None" = None, + objectID: EntityIdentifier | None = None, + referencedObjectID: EntityIdentifier | None = None, updateNumber: uint16 = 0, forceID: enum8 = 0, # [UID 6] requesterID: "SimulationAddress | None" = None, @@ -4103,9 +4105,9 @@ def __init__(self, objectType: "ObjectType | None" = None, linearSegmentParameters: list["LinearSegmentParameter"] | None = None): super(LinearObjectStatePdu, self).__init__() - self.objectID = objectID or EntityID() + self.objectID = objectID or EntityIdentifier() """Object in synthetic environment""" - self.referencedObjectID = referencedObjectID or EntityID() + self.referencedObjectID = referencedObjectID or EntityIdentifier() """Object with which this point object is associated""" self.updateNumber = updateNumber """unique update number of each state transition of an object""" @@ -4202,7 +4204,7 @@ class IntercomSignalPdu(RadioCommunicationsFamilyPdu): pduType: enum8 = 31 # [UID 4] def __init__(self, - entityID: "EntityID | ObjectID | UnattachedIdentifier | None" = None, + entityID: "EntityIdentifier | ObjectIdentifier | UnattachedIdentifier | None" = None, communicationsDeviceID: uint16 = 0, encodingScheme: struct16 = 00, tdlType: uint16 = 0, # [UID 178] @@ -4210,7 +4212,7 @@ def __init__(self, samples: uint16 = 0, data: list[bytes] | None = None): super(IntercomSignalPdu, self).__init__() - self.entityID = entityID or EntityID() + self.entityID = entityID or EntityIdentifier() self.communicationsDeviceID = communicationsDeviceID self.encodingScheme = encodingScheme self.tdlType = tdlType @@ -4290,13 +4292,13 @@ class ResupplyReceivedPdu(LogisticsFamilyPdu): pduType: enum8 = 7 # [UID 4] def __init__(self, - receivingEntityID: "EntityID | None" = None, - supplyingEntityID: "EntityID | None" = None, + receivingEntityID: EntityIdentifier | None = None, + supplyingEntityID: EntityIdentifier | None = None, supplies: list["SupplyQuantity"] | None = None): super(ResupplyReceivedPdu, self).__init__() - self.receivingEntityID = receivingEntityID or EntityID() + self.receivingEntityID = receivingEntityID or EntityIdentifier() """Entity that is receiving service. Shall be represented by Entity Identifier record (see 6.2.28)""" - self.supplyingEntityID = supplyingEntityID or EntityID() + self.supplyingEntityID = supplyingEntityID or EntityIdentifier() """Entity that is supplying. Shall be represented by Entity Identifier record (see 6.2.28)""" self.padding1: uint8 = 0 self.padding2: uint16 = 0 @@ -4341,12 +4343,12 @@ class WarfareFamilyPdu(Pdu): protocolFamily: enum8 = 2 # [UID 5] def __init__(self, - firingEntityID: "EntityID | None" = None, - targetEntityID: "EntityID | None" = None): + firingEntityID: EntityIdentifier | None = None, + targetEntityID: EntityIdentifier | None = None): super(WarfareFamilyPdu, self).__init__() - self.firingEntityID = firingEntityID or EntityID() + self.firingEntityID = firingEntityID or EntityIdentifier() """ID of the entity that shot""" - self.targetEntityID = targetEntityID or EntityID() + self.targetEntityID = targetEntityID or EntityIdentifier() """ID of the entity that is being shot at""" def serialize(self, outputStream): @@ -4371,24 +4373,24 @@ class CollisionElasticPdu(EntityInformationFamilyPdu): pduType: enum8 = 66 # [UID 4] def __init__(self, - issuingEntityID: "EntityID | None" = None, - collidingEntityID: "EntityID | None" = None, + issuingEntityID: EntityIdentifier | None = None, + collidingEntityID: EntityIdentifier | None = None, collisionEventID: "EventIdentifier | None" = None, - contactVelocity: "Vector3Float | None" = None, + contactVelocity: Vector3Float | None = None, mass: float32 = 0.0, # in kg - locationOfImpact: "Vector3Float | None" = None, + locationOfImpact: Vector3Float | None = None, collisionIntermediateResultXX: float32 = 0.0, collisionIntermediateResultXY: float32 = 0.0, collisionIntermediateResultXZ: float32 = 0.0, collisionIntermediateResultYY: float32 = 0.0, collisionIntermediateResultYZ: float32 = 0.0, collisionIntermediateResultZZ: float32 = 0.0, - unitSurfaceNormal: "Vector3Float | None" = None, + unitSurfaceNormal: Vector3Float | None = None, coefficientOfRestitution: float32 = 0.0): super(CollisionElasticPdu, self).__init__() - self.issuingEntityID = issuingEntityID or EntityID() + self.issuingEntityID = issuingEntityID or EntityIdentifier() """This field shall identify the entity that is issuing the PDU and shall be represented by an Entity Identifier record (see 6.2.28)""" - self.collidingEntityID = collidingEntityID or EntityID() + self.collidingEntityID = collidingEntityID or EntityIdentifier() """This field shall identify the entity that has collided with the issuing entity. This field shall be a valid identifier of an entity or server capable of responding to the receipt of this Collision-Elastic PDU. This field shall be represented by an Entity Identifier record (see 6.2.28).""" self.collisionEventID = collisionEventID or EventIdentifier() """This field shall contain an identification generated by the issuing simulation application to associate related collision events. This field shall be represented by an Event Identifier record (see 6.2.34).""" @@ -4566,12 +4568,12 @@ class SimulationManagementWithReliabilityFamilyPdu(Pdu): protocolFamily: enum8 = 10 # [UID 5] def __init__(self, - originatingEntityID: "EntityID | None" = None, - receivingEntityID: "EntityID | None" = None): + originatingEntityID: EntityIdentifier | None = None, + receivingEntityID: EntityIdentifier | None = None): super(SimulationManagementWithReliabilityFamilyPdu, self).__init__() - self.originatingEntityID = originatingEntityID or EntityID() + self.originatingEntityID = originatingEntityID or EntityIdentifier() """Object originating the request""" - self.receivingEntityID = receivingEntityID or EntityID() + self.receivingEntityID = receivingEntityID or EntityIdentifier() """Object with which this point object is associated""" def serialize(self, outputStream): @@ -4660,9 +4662,9 @@ class DesignatorPdu(DistributedEmissionsFamilyPdu): pduType: enum8 = 24 # [UID 4] def __init__(self, - designatingEntityID: EntityID | None = None, + designatingEntityID: EntityIdentifier | None = None, codeName: enum16 = 0, # [UID 80] - designatedEntityID: EntityID | None = None, + designatedEntityID: EntityIdentifier | None = None, designatorCode: enum16 = 0, # [UID 81] designatorPower: float32 = 0.0, # in W designatorWavelength: float32 = 0.0, # in microns @@ -4672,11 +4674,11 @@ def __init__(self, deadReckoningAlgorithm: enum8 = 0, # [UID 44] entityLinearAcceleration: Vector3Float | None = None): super(DesignatorPdu, self).__init__() - self.designatingEntityID = designatingEntityID or EntityID() + self.designatingEntityID = designatingEntityID or EntityIdentifier() """ID of the entity designating""" self.codeName = codeName """This field shall specify a unique emitter database number assigned to differentiate between otherwise similar or identical emitter beams within an emitter system.""" - self.designatedEntityID = designatedEntityID or EntityID() + self.designatedEntityID = designatedEntityID or EntityIdentifier() """ID of the entity being designated""" self.designatorCode = designatorCode """This field shall identify the designator code being used by the designating entity""" @@ -4780,7 +4782,7 @@ class EntityStatePdu(EntityInformationFamilyPdu): pduType: enum8 = 1 # [UID 4] def __init__(self, - entityID: EntityID | None = None, + entityID: EntityIdentifier | None = None, forceId: enum8 = 0, # [UID 6] entityType: EntityType | None = None, alternativeEntityType: EntityType | None = None, @@ -4793,7 +4795,7 @@ def __init__(self, capabilities: uint32 = 0, # [UID 55] variableParameters: list[VariableParameter] | None = None): super(EntityStatePdu, self).__init__() - self.entityID = entityID or EntityID() + self.entityID = entityID or EntityIdentifier() """Unique ID for an entity that is tied to this state information""" self.forceId = forceId """What force this entity is affiliated with, eg red, blue, neutral, etc""" @@ -4927,7 +4929,7 @@ class TransmitterPdu(RadioCommunicationsFamilyPdu): pduType: enum8 = 25 # [UID 4] def __init__(self, - radioReferenceID: "EntityID | ObjectIdentifier | None" = None, + radioReferenceID: EntityIdentifier | ObjectIdentifier | None = None, radioNumber: uint16 = 0, radioEntityType: EntityType | None = None, transmitState: enum8 = 0, # [UID 164] @@ -4945,7 +4947,7 @@ def __init__(self, antennaPattern: AntennaPatternRecord | None = None, variableTransmitterParameters: Sequence[VariableTransmitterParametersRecord] | None = None): super(TransmitterPdu, self).__init__() - self.radioReferenceID = radioReferenceID or EntityID() + self.radioReferenceID = radioReferenceID or EntityIdentifier() """ID of the entity that is the source of the communication""" self.radioNumber = radioNumber """particular radio within an entity""" @@ -5109,12 +5111,12 @@ class ElectromagneticEmissionsPdu(DistributedEmissionsFamilyPdu): pduType: enum8 = 23 # [UID 4] def __init__(self, - emittingEntityID: "EntityID | None" = None, + emittingEntityID: EntityIdentifier | None = None, eventID: "EventIdentifier | None" = None, stateUpdateIndicator: enum8 = 0, # [UID 77] systems: list["EmissionSystemRecord"] | None = None): super(ElectromagneticEmissionsPdu, self).__init__() - self.emittingEntityID = emittingEntityID or EntityID() + self.emittingEntityID = emittingEntityID or EntityIdentifier() """ID of the entity emitting""" self.eventID = eventID or EventIdentifier() self.stateUpdateIndicator = stateUpdateIndicator @@ -5224,8 +5226,8 @@ class EmissionSystemRecord: def __init__(self, systemDataLength: uint8 = 0, # length in 32-bit words, 0 if exceed 255 emitterSystem: "EmitterSystem | None" = None, - location: "Vector3Float | None" = None, - beamRecords: list["EmissionSystemBeamRecord"] | None = None): + location: Vector3Float | None = None, + beamRecords: list[EmissionSystemBeamRecord] | None = None): self.systemDataLength = systemDataLength """this field shall specify the length of this emitter system's data in 32-bit words.""" self.paddingForEmissionsPdu: uint8 = 0 @@ -5269,13 +5271,13 @@ class ResupplyOfferPdu(LogisticsFamilyPdu): pduType: enum8 = 6 # [UID 4] def __init__(self, - receivingEntityID: "EntityID | None" = None, - supplyingEntityID: "EntityID | None" = None, + receivingEntityID: "EntityIdentifier | None" = None, + supplyingEntityID: "EntityIdentifier | None" = None, supplies: list["SupplyQuantity"] | None = None): super(ResupplyOfferPdu, self).__init__() - self.receivingEntityID = receivingEntityID or EntityID() + self.receivingEntityID = receivingEntityID or EntityIdentifier() """Field identifies the Entity and respective Entity Record ID that is receiving service (see 6.2.28), Section 7.4.3""" - self.supplyingEntityID = supplyingEntityID or EntityID() + self.supplyingEntityID = supplyingEntityID or EntityIdentifier() """Identifies the Entity and respective Entity ID Record that is supplying (see 6.2.28), Section 7.4.3""" self.padding1: uint8 = 0 self.padding2: uint16 = 0 @@ -5495,8 +5497,8 @@ class PointObjectStatePdu(SyntheticEnvironmentFamilyPdu): pduType: enum8 = 43 # [UID 4] def __init__(self, - objectID: EntityID | None = None, - referencedObjectID: EntityID | None = None, + objectID: EntityIdentifier | None = None, + referencedObjectID: EntityIdentifier | None = None, updateNumber: uint16 = 0, forceID: enum8 = 0, # [UID 6] modifications : enum8 = 0, # [UID 240] @@ -5508,9 +5510,9 @@ def __init__(self, receivingID: SimulationAddress | None = None): super(PointObjectStatePdu, self).__init__() # TODO: Validate ObjectID? - self.objectID = objectID or EntityID() + self.objectID = objectID or EntityIdentifier() """Object in synthetic environment""" - self.referencedObjectID = referencedObjectID or EntityID() + self.referencedObjectID = referencedObjectID or EntityIdentifier() """Object with which this point object is associated""" self.updateNumber = updateNumber """unique update number of each state transition of an object""" @@ -5940,8 +5942,8 @@ class ArealObjectStatePdu(SyntheticEnvironmentFamilyPdu): pduType: enum8 = 45 # [UID 4] def __init__(self, - objectID: EntityID | None = None, - referencedObjectID: EntityID | None = None, + objectID: EntityIdentifier | None = None, + referencedObjectID: EntityIdentifier | None = None, updateNumber: uint16 = 0, forceId: enum8 = 0, # [UID 6] modifications: enum8 = 0, # [UID 242] @@ -5953,9 +5955,9 @@ def __init__(self, objectLocation: list[WorldCoordinates] | None = None): super(ArealObjectStatePdu, self).__init__() # TODO: validate object ID? - self.objectID = objectID or EntityID() + self.objectID = objectID or EntityIdentifier() """Object in synthetic environment""" - self.referencedObjectID = referencedObjectID or EntityID() + self.referencedObjectID = referencedObjectID or EntityIdentifier() """Object with which this point object is associated""" self.updateNumber = updateNumber """unique update number of each state transition of an object""" @@ -6311,7 +6313,7 @@ def __init__(self, munitionType: "EntityType | None" = None, shotStartTime: "ClockTime | None" = None, cumulativeShotTime: float32 = 0.0, # in seconds - apertureEmitterLocation: "Vector3Float | None" = None, + apertureEmitterLocation: Vector3Float | None = None, apertureDiameter: float32 = 0.0, # in meters wavelength: float32 = 0.0, # in meters peakIrradiance=0.0, @@ -6410,7 +6412,7 @@ class DetonationPdu(WarfareFamilyPdu): pduType: enum8 = 3 # [UID 4] def __init__(self, - explodingEntityID: EntityID | None = None, + explodingEntityID: EntityIdentifier | None = None, eventID: EventIdentifier | None = None, velocity: Vector3Float | None = None, location: WorldCoordinates | None = None, @@ -6419,7 +6421,7 @@ def __init__(self, detonationResult: enum8 = 0, # [UID 62] variableParameters: list[VariableParameter] | None = None): super(DetonationPdu, self).__init__() - self.explodingEntityID = explodingEntityID or EntityID() + self.explodingEntityID = explodingEntityID or EntityIdentifier() """ID of the expendable entity, Section 7.3.3""" self.eventID = eventID or EventIdentifier() self.velocity = velocity or Vector3Float() @@ -6638,10 +6640,10 @@ class EntityDamageStatusPdu(WarfareFamilyPdu): pduType: enum8 = 69 # [UID 4] def __init__(self, - damagedEntityID: "EntityID | None" = None, + damagedEntityID: "EntityIdentifier | None" = None, damageDescriptionRecords=None): super(EntityDamageStatusPdu, self).__init__() - self.damagedEntityID = damagedEntityID or EntityID() + self.damagedEntityID = damagedEntityID or EntityIdentifier() """Field shall identify the damaged entity (see 6.2.28), Section 7.3.4 COMPLETE""" self.padding1: uint16 = 0 self.padding2: uint16 = 0 @@ -6685,7 +6687,7 @@ class FirePdu(WarfareFamilyPdu): pduType: enum8 = 2 # [UID 4] def __init__(self, - munitionExpendableID: EntityID | None = None, + munitionExpendableID: EntityIdentifier | None = None, eventID: EventIdentifier | None = None, fireMissionIndex: uint32 = 0, location: WorldCoordinates | None = None, @@ -6693,7 +6695,7 @@ def __init__(self, velocity: Vector3Float | None = None, range_: float32 = 0.0): # in meters super(FirePdu, self).__init__() - self.munitionExpendableID = munitionExpendableID or EntityID() + self.munitionExpendableID = munitionExpendableID or EntityIdentifier() """This field shall specify the entity identification of the fired munition or expendable. This field shall be represented by an Entity Identifier record (see 6.2.28).""" self.eventID = eventID or EventIdentifier() """This field shall contain an identification generated by the firing entity to associate related firing and detonation events. This field shall be represented by an Event Identifier record (see 6.2.34).""" @@ -6742,7 +6744,7 @@ class ReceiverPdu(RadioCommunicationsFamilyPdu): def __init__(self, receiverState: enum16 = 0, # [UID 179] receivedPower: float32 = 0.0, # in decibel milliwatts - transmitterEntityID: "EntityID | ObjectIdentifier | UnattachedIdentifier | None" = None, + transmitterEntityID: EntityIdentifier | ObjectIdentifier | UnattachedIdentifier | None = None, transmitterRadioId: uint16 = 0): super(ReceiverPdu, self).__init__() self.receiverState = receiverState @@ -6750,7 +6752,7 @@ def __init__(self, self.padding1: uint16 = 0 self.receivedPower = receivedPower """received power""" - self.transmitterEntityID = transmitterEntityID or EntityID() + self.transmitterEntityID = transmitterEntityID or EntityIdentifier() """ID of transmitter""" self.transmitterRadioId = transmitterRadioId """ID of transmitting radio""" @@ -6784,8 +6786,8 @@ class UaPdu(DistributedEmissionsFamilyPdu): pduType: enum8 = 29 # [UID 4] def __init__(self, - emittingEntityID: "EntityID | None" = None, - eventID: "EventIdentifier | None" = None, + emittingEntityID: EntityIdentifier | None = None, + eventID: EventIdentifier | None = None, stateChangeIndicator: enum8 = 0, # [UID 143] passiveParameterIndex: enum16 = 0, # [UID 148] propulsionPlantConfiguration: struct8 = 0, # [UID 149] @@ -6793,7 +6795,7 @@ def __init__(self, apaData: list | None = None, emitterSystems: list | None = None): super(UaPdu, self).__init__() - self.emittingEntityID = emittingEntityID or EntityID() + self.emittingEntityID = emittingEntityID or EntityIdentifier() """ID of the entity that is the source of the emission""" self.eventID = eventID or EventIdentifier() """ID of event""" @@ -6882,19 +6884,19 @@ class IntercomControlPdu(RadioCommunicationsFamilyPdu): def __init__(self, controlType: enum8 = 0, # [UID 180] communicationsChannelType: struct8 = 0, # [UID 416], [UID 181] - sourceEntityID: "EntityID | UnattachedIdentifier | None" = None, + sourceEntityID: "EntityIdentifier | UnattachedIdentifier | None" = None, sourceCommunicationsDeviceID: uint16 = 0, sourceLineID: uint8 = 0, transmitPriority: uint8 = 0, transmitLineState: enum8 = 0, # [UID 183] command: enum8 = 0, # [UID 182] - masterEntityID: "EntityID | UnattachedIdentifier | None" = None, + masterEntityID: "EntityIdentifier | UnattachedIdentifier | None" = None, masterCommunicationsDeviceID: uint16 = 0, intercomParameters: "IntercomCommunicationsParameters | None" = None): super(IntercomControlPdu, self).__init__() self.controlType = controlType self.communicationsChannelType = communicationsChannelType - self.sourceEntityID = sourceEntityID or EntityID() + self.sourceEntityID = sourceEntityID or EntityIdentifier() self.sourceCommunicationsDeviceID = sourceCommunicationsDeviceID """The specific intercom device being simulated within an entity.""" self.sourceLineID = sourceLineID @@ -6905,7 +6907,7 @@ def __init__(self, """current transmit state of the line""" self.command = command """detailed type requested.""" - self.masterEntityID = masterEntityID or EntityID() + self.masterEntityID = masterEntityID or EntityIdentifier() """eid of the entity that has created this intercom channel.""" self.masterCommunicationsDeviceID = masterCommunicationsDeviceID """specific intercom device that has created this intercom channel""" @@ -6963,7 +6965,7 @@ class SignalPdu(RadioCommunicationsFamilyPdu): pduType: enum8 = 26 # [UID 4] def __init__(self, - entityID: "EntityID | ObjectIdentifier | UnattachedIdentifier | None" = None, + entityID: EntityIdentifier | ObjectIdentifier | UnattachedIdentifier | None = None, radioID: uint16 = 0, encodingScheme: struct16 = 0, # (Table 177), [UID 271], [UID 270] tdlType: enum16 = 0, # [UID 178] @@ -6971,7 +6973,7 @@ def __init__(self, samples: uint16 = 0, data: list[bytes] | None = None): super(SignalPdu, self).__init__() - self.entityID = entityID or EntityID() + self.entityID = entityID or EntityIdentifier() self.radioID = radioID self.encodingScheme = encodingScheme self.tdlType = tdlType @@ -7056,14 +7058,14 @@ class SeesPdu(DistributedEmissionsFamilyPdu): pduType: enum8 = 30 # [UID 4] def __init__(self, - originatingEntityID: "EntityID | None" = None, + originatingEntityID: EntityIdentifier | None = None, infraredSignatureRepresentationIndex: uint16 = 0, acousticSignatureRepresentationIndex: uint16 = 0, radarCrossSectionSignatureRepresentationIndex: uint16 = 0, propulsionSystemData: list | None = None, vectoringSystemData: list | None = None): super(SeesPdu, self).__init__() - self.orginatingEntityID = originatingEntityID or EntityID() + self.orginatingEntityID = originatingEntityID or EntityIdentifier() self.infraredSignatureRepresentationIndex = infraredSignatureRepresentationIndex self.acousticSignatureRepresentationIndex = acousticSignatureRepresentationIndex self.radarCrossSectionSignatureRepresentationIndex = radarCrossSectionSignatureRepresentationIndex @@ -7257,14 +7259,14 @@ class MinefieldResponseNackPdu(MinefieldFamilyPdu): pduType: enum8 = 40 # [UID 4] def __init__(self, - minefieldID: "EntityID | None" = None, - requestingEntityID: "EntityID | None" = None, + minefieldID: EntityIdentifier | None = None, + requestingEntityID: EntityIdentifier | None = None, requestID: uint32 = 0, missingPduSequenceNumbers: list[uint8] | None = None): super(MinefieldResponseNackPdu, self).__init__() # TODO: validate EntityID? - self.minefieldID = minefieldID or EntityID() - self.requestingEntityID = requestingEntityID or EntityID() + self.minefieldID = minefieldID or EntityIdentifier() + self.requestingEntityID = requestingEntityID or EntityIdentifier() self.requestID = requestID self.missingPduSequenceNumbers = missingPduSequenceNumbers or [] """PDU sequence numbers that were missing""" @@ -7357,16 +7359,16 @@ class IsPartOfPdu(EntityManagementFamilyPdu): pduType: enum8 = 36 # [UID 4] def __init__(self, - originatingEntityID: "EntityID | None" = None, - receivingEntityID: "EntityID | None" = None, + originatingEntityID: EntityIdentifier | None = None, + receivingEntityID: EntityIdentifier | None = None, relationship: "Relationship | None" = None, - partLocation: "Vector3Float | None" = None, + partLocation: Vector3Float | None = None, namedLocationID: "NamedLocationIdentification | None" = None, partEntityType: "EntityType | None" = None): super(IsPartOfPdu, self).__init__() - self.orginatingEntityID = originatingEntityID or EntityID() + self.orginatingEntityID = originatingEntityID or EntityIdentifier() """ID of entity originating PDU""" - self.receivingEntityID = receivingEntityID or EntityID() + self.receivingEntityID = receivingEntityID or EntityIdentifier() """ID of entity receiving PDU""" self.relationship = relationship or Relationship() """relationship of joined parts""" @@ -7518,7 +7520,7 @@ def parse(self, inputStream): element.parse(inputStream) self.aggregateIDs.append(element) for idx in range(0, numberOfEntityIDs): - element = EntityID() + element = EntityIdentifier() element.parse(inputStream) self.entityIDs.append(element) for idx in range(0, numberOfSilentAggregateSystems): diff --git a/tests/test_ElectromageneticEmissionPdu.py b/tests/test_ElectromageneticEmissionPdu.py index 841489f..943ab09 100644 --- a/tests/test_ElectromageneticEmissionPdu.py +++ b/tests/test_ElectromageneticEmissionPdu.py @@ -22,9 +22,9 @@ def test_parse(self): #self.assertEqual(0, pdu.timestamp) self.assertEqual(108, pdu.length) - self.assertEqual(23, pdu.emittingEntityID.siteID) - self.assertEqual(1, pdu.emittingEntityID.applicationID) - self.assertEqual(2, pdu.emittingEntityID.entityID) + self.assertEqual(23, pdu.emittingEntityID.simulationAddress.site) + self.assertEqual(1, pdu.emittingEntityID.simulationAddress.application) + self.assertEqual(2, pdu.emittingEntityID.entityNumber) self.assertEqual(23, pdu.eventID.simulationAddress.site) self.assertEqual(1, pdu.eventID.simulationAddress.application) @@ -55,9 +55,9 @@ def test_parse(self): self.assertEqual(0, pdu.systems[0].beamRecords[0].highDensityTrackJam) self.assertEqual(0, pdu.systems[0].beamRecords[0].jammingModeSequence) - self.assertEqual(23, pdu.systems[0].beamRecords[0].trackJamRecords[0].entityID.siteID) - self.assertEqual(1, pdu.systems[0].beamRecords[0].trackJamRecords[0].entityID.applicationID) - self.assertEqual(1, pdu.systems[0].beamRecords[0].trackJamRecords[0].entityID.entityID) + self.assertEqual(23, pdu.systems[0].beamRecords[0].trackJamRecords[0].entityID.simulationAddress.site) + self.assertEqual(1, pdu.systems[0].beamRecords[0].trackJamRecords[0].entityID.simulationAddress.application) + self.assertEqual(1, pdu.systems[0].beamRecords[0].trackJamRecords[0].entityID.entityNumber) self.assertEqual(0, pdu.systems[0].beamRecords[0].trackJamRecords[0].emitterNumber) self.assertEqual(0, pdu.systems[0].beamRecords[0].trackJamRecords[0].beamNumber) diff --git a/tests/test_EntityStatePdu.py b/tests/test_EntityStatePdu.py index b7227e6..1cd8c26 100644 --- a/tests/test_EntityStatePdu.py +++ b/tests/test_EntityStatePdu.py @@ -24,9 +24,9 @@ def test_parse(self): self.assertEqual(0, pdu.padding) # Entity ID - self.assertEqual(42, pdu.entityID.siteID) - self.assertEqual(4, pdu.entityID.applicationID) - self.assertEqual(26, pdu.entityID.entityID) + self.assertEqual(42, pdu.entityID.simulationAddress.site) + self.assertEqual(4, pdu.entityID.simulationAddress.application) + self.assertEqual(26, pdu.entityID.entityNumber) # Force ID self.assertEqual(1, pdu.forceId) diff --git a/tests/test_SignalPdu.py b/tests/test_SignalPdu.py index d703d71..e39c2e5 100644 --- a/tests/test_SignalPdu.py +++ b/tests/test_SignalPdu.py @@ -25,9 +25,9 @@ def test_parse_and_serialize(self): #self.assertEqual(0, pdu.timestamp) self.assertEqual(1056, pdu.length) - self.assertEqual(1677, pdu.entityID.siteID) - self.assertEqual(1678, pdu.entityID.applicationID) - self.assertEqual(169, pdu.entityID.entityID ) + self.assertEqual(1677, pdu.entityID.simulationAddress.site) + self.assertEqual(1678, pdu.entityID.simulationAddress.application) + self.assertEqual(169, pdu.entityID.entityNumber) self.assertEqual(1, pdu.radioID) self.assertEqual(4, pdu.encodingScheme) self.assertEqual(0, pdu.tdlType) diff --git a/tests/test_TransmitterPdu.py b/tests/test_TransmitterPdu.py index a34df0f..4cc36f5 100644 --- a/tests/test_TransmitterPdu.py +++ b/tests/test_TransmitterPdu.py @@ -22,9 +22,9 @@ def test_parse(self): #self.assertEqual(0, pdu.timestamp) self.assertEqual(104, pdu.length) - self.assertEqual(1677, pdu.radioReferenceID.siteID) - self.assertEqual(1678, pdu.radioReferenceID.applicationID) - self.assertEqual(169, pdu.radioReferenceID.entityID ) + self.assertEqual(1677, pdu.radioReferenceID.simulationAddress.site) + self.assertEqual(1678, pdu.radioReferenceID.simulationAddress.application) + self.assertEqual(169, pdu.radioReferenceID.entityNumber) self.assertEqual(1, pdu.radioNumber) self.assertEqual(2, pdu.transmitState) self.assertEqual(10000000000, pdu.frequency) From 945d5e42440b7e0429322ca35b9164205ce4b973 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 01:53:24 +0000 Subject: [PATCH 16/37] refactor: remove unused class EntityID --- opendis/record/__init__.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index c5740e8..506ee4e 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -83,28 +83,6 @@ def parse(self, inputStream: DataInputStream) -> None: self.z = inputStream.read_float64() -class EntityID: - """more laconically named EntityIdentifier""" - - def __init__(self, siteID=0, applicationID=0, entityID=0): - self.siteID = siteID - self.applicationID = applicationID - self.entityID = entityID - - def serialize(self, outputStream: DataOutputStream) -> None: - """serialize the class""" - outputStream.write_unsigned_short(self.siteID) - outputStream.write_unsigned_short(self.applicationID) - outputStream.write_unsigned_short(self.entityID) - - def parse(self, inputStream: DataInputStream) -> None: - """Parse a message. This may recursively call embedded objects.""" - - self.siteID = inputStream.read_unsigned_short() - self.applicationID = inputStream.read_unsigned_short() - self.entityID = inputStream.read_unsigned_short() - - class EntityIdentifier: """Section 6.2.28 From 6f6a01bc2658c5ddb3b507ba2603577df9d4fcc4 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 02:02:50 +0000 Subject: [PATCH 17/37] fix: enforce Record interface for EntityIdentifier, implement marshalledSize() method --- opendis/record/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 506ee4e..8ba2b9a 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -83,7 +83,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.z = inputStream.read_float64() -class EntityIdentifier: +class EntityIdentifier(base.Record): """Section 6.2.28 Entity Identifier. Unique ID for entities in the world. Consists of a @@ -95,6 +95,9 @@ def __init__(self, entityNumber: uint16 = 0): self.simulationAddress = simulationAddress or SimulationAddress() self.entityNumber = entityNumber + + def marshalledSize(self) -> int: + return self.simulationAddress.marshalledSize() + 2 def serialize(self, outputStream: DataOutputStream) -> None: self.simulationAddress.serialize(outputStream) From 74166ce57943891675c90d7e880b784b790a3d99 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 02:04:51 +0000 Subject: [PATCH 18/37] refactor: replace all use of EntityID with EntityIdentifier --- opendis/record/__init__.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 8ba2b9a..9f52fa2 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -946,7 +946,7 @@ def __init__(self, targetSpotEntityLocation: Vector3Float | None = None, targetSpotVelocity: Vector3Float | None = None, # in m/s targetSpotAcceleration: Vector3Float | None = None, # in m/s^2 - targetEntityID: "EntityID | None" = None, + targetEntityID: EntityIdentifier | None = None, targetComponentID: enum8 = 0, # [UID 314] beamSpotType: enum8 = 0, # [UID 311] beamSpotCrossSectionSemiMajorAxis: float32 = 0.0, # in meters @@ -959,7 +959,7 @@ def __init__(self, ) self.targetSpotVelocity = targetSpotVelocity or Vector3Float() self.targetSpotAcceleration = targetSpotAcceleration or Vector3Float() - self.targetEntityID = targetEntityID or EntityID() + self.targetEntityID = targetEntityID or EntityIdentifier() self.targetComponentID = targetComponentID self.beamSpotType = beamSpotType self.beamSpotCrossSectionSemiMajorAxis = beamSpotCrossSectionSemiMajorAxis @@ -1016,22 +1016,18 @@ class DirectedEnergyTargetEnergyDeposition(base.Record): """ def __init__(self, - targetEntityID: "EntityIdentifier | None" = None, + targetEntityID: EntityIdentifier | None = None, peakIrradiance: float32 = 0.0): # in W/m^2 - self.targetEntityID = targetEntityID or EntityID() - """Unique ID of the target entity.""" + self.targetEntityID = targetEntityID or EntityIdentifier() self.padding: uint16 = 0 self.peakIrradiance = peakIrradiance - """Peak irrandiance""" - def serialize(self, outputStream): - """serialize the class""" + def serialize(self, outputStream: DataOutputStream) -> None: self.targetEntityID.serialize(outputStream) outputStream.write_uint16(self.padding) outputStream.write_float32(self.peakIrradiance) - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" + def parse(self, inputStream: DataInputStream) -> None: self.targetEntityID.parse(inputStream) self.padding = inputStream.read_uint16() self.peakIrradiance = inputStream.read_float32() From c300159fa6f9371ef27728c50af6f3e615f3f779 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 02:23:16 +0000 Subject: [PATCH 19/37] refactor: remove unused class EntityID --- opendis/dis7.py | 1 - 1 file changed, 1 deletion(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 63b225f..2a9529a 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -13,7 +13,6 @@ EulerAngles, Vector3Float, WorldCoordinates, - EntityID, EntityIdentifier, EventIdentifier, SimulationAddress, From 7b33978caf67439c8a650c8374901af3ba1c66d6 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 02:24:08 +0000 Subject: [PATCH 20/37] refactor: implement VariableRecord interface for Directed Energy records --- opendis/record/__init__.py | 82 ++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 9f52fa2..80b8d09 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -879,36 +879,45 @@ class DirectedEnergyAreaAimpoint(base.VariableRecord): recordType: enum32 = 4001 # [UID 66] def __init__(self, - recordLength: uint16 = 0, - beamAntennaParameters: list | None = None, - directedEnergyTargetEnergyDepositions: list | None -= None): - self.recordLength = recordLength + beamAntennaPatterns: list["BeamAntennaPattern"] | None = None, + directedEnergyTargetEnergyDepositions: list["DirectedEnergyTargetEnergyDeposition"] | None = None): self.padding: uint16 = 0 - self.beamAntennaParameters = beamAntennaParameters or [] - self.directedEnergyTargetEnergyDepositionRecordList = directedEnergyTargetEnergyDepositions or [] + self.beamAntennaPatterns: list["BeamAntennaPattern"] = beamAntennaPatterns or [] + self.directedEnergyTargetEnergyDepositions: list["DirectedEnergyTargetEnergyDeposition"] = directedEnergyTargetEnergyDepositions or [] @property - def beamAntennaPatternRecordCount(self) -> uint16: - return len(self.beamAntennaParameters) + def recordLength(self) -> uint16: + return self.marshalledSize() + + @property + def beamAntennaPatternCount(self) -> uint16: + return len(self.beamAntennaPatterns) @property - def directedEnergyTargetEnergyDepositionRecordCount(self) -> uint16: - return len(self.directedEnergyTargetEnergyDepositionRecordList) + def directedEnergyTargetEnergyDepositionCount(self) -> uint16: + return len(self.directedEnergyTargetEnergyDepositions) + def marshalledSize(self) -> int: + size = 8 # recordType, recordLength, padding + for record in self.beamAntennaPatterns: + size += record.marshalledSize() + for record in self.directedEnergyTargetEnergyDepositions: + size += record.marshalledSize() + return size + def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_uint32(self.recordType) outputStream.write_uint16(self.recordLength) outputStream.write_uint16(self.padding) - outputStream.write_uint16(self.beamAntennaPatternRecordCount) + outputStream.write_uint16(self.beamAntennaPatternCount) outputStream.write_uint16( - self.directedEnergyTargetEnergyDepositionRecordCount + self.directedEnergyTargetEnergyDepositionCount ) - for anObj in self.beamAntennaParameters: - anObj.serialize(outputStream) + for record in self.beamAntennaPatterns: + record.serialize(outputStream) - for anObj in self.directedEnergyTargetEnergyDepositionRecordList: - anObj.serialize(outputStream) + for record in self.directedEnergyTargetEnergyDepositions: + record.serialize(outputStream) def parse(self, inputStream: DataInputStream, @@ -918,17 +927,17 @@ def parse(self, assert isinstance(bytelength, int) recordLength = inputStream.read_uint16() self.padding = inputStream.read_uint16() - beamAntennaPatternRecordCount = inputStream.read_uint16() - directedEnergyTargetEnergyDepositionRecordCount = inputStream.read_uint16() - for _ in range(0, beamAntennaPatternRecordCount): - element = null() - element.parse(inputStream) - self.beamAntennaParameters.append(element) + beamAntennaPatternCount = inputStream.read_uint16() + directedEnergyTargetEnergyDepositionCount = inputStream.read_uint16() + for _ in range(0, beamAntennaPatternCount): + record = BeamAntennaPattern() + record.parse(inputStream) + self.beamAntennaPatterns.append(record) - for idx in range(0, directedEnergyTargetEnergyDepositionRecordCount): - element = null() - element.parse(inputStream) - self.directedEnergyTargetEnergyDepositionRecordList.append(element) + for idx in range(0, directedEnergyTargetEnergyDepositionCount): + record = DirectedEnergyTargetEnergyDeposition() + record.parse(inputStream) + self.directedEnergyTargetEnergyDepositions.append(record) class DirectedEnergyPrecisionAimpoint(base.VariableRecord): @@ -939,7 +948,6 @@ class DirectedEnergyPrecisionAimpoint(base.VariableRecord): weapon would not fire unless a target is known and is currently tracked. """ recordType: enum32 = 4000 - recordLength: uint16 = 88 def __init__(self, targetSpotLocation: WorldCoordinates | None = None, @@ -955,8 +963,7 @@ def __init__(self, peakIrradiance: float32 = 0.0): # in W/m^2 self.padding: uint16 = 0 self.targetSpotLocation = targetSpotLocation or WorldCoordinates() - self.targetSpotEntityLocation = targetSpotEntityLocation or Vector3Float( - ) + self.targetSpotEntityLocation = targetSpotEntityLocation or Vector3Float() self.targetSpotVelocity = targetSpotVelocity or Vector3Float() self.targetSpotAcceleration = targetSpotAcceleration or Vector3Float() self.targetEntityID = targetEntityID or EntityIdentifier() @@ -968,6 +975,13 @@ def __init__(self, self.peakIrradiance = peakIrradiance self.padding2: uint32 = 0 + @property + def recordLength(self) -> uint16: + return self.marshalledSize() + + def marshalledSize(self) -> int: + return 96 + def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_uint32(self.recordType) outputStream.write_uint16(self.recordLength) @@ -1012,7 +1026,10 @@ def parse(self, class DirectedEnergyTargetEnergyDeposition(base.Record): """6.2.20.4 DE Target Energy Deposition record - DE energy deposition properties for a target entity. + Directed energy deposition properties for a target entity shall be + communicated using the DE Target Energy Deposition record. This record is + required to be included inside another DE record as it does not have a + record type. """ def __init__(self, @@ -1022,6 +1039,9 @@ def __init__(self, self.padding: uint16 = 0 self.peakIrradiance = peakIrradiance + def marshalledSize(self) -> int: + return self.targetEntityID.marshalledSize() + 6 + def serialize(self, outputStream: DataOutputStream) -> None: self.targetEntityID.serialize(outputStream) outputStream.write_uint16(self.padding) From 7a1c41b1d57613640ea5ca1b385c90917d3fe5e2 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 04:17:23 +0000 Subject: [PATCH 21/37] refactor: distinguish VariableRecords and StandardVariableRecords Standard Variable (SV) records have recordType and recordLength properties --- opendis/record/base.py | 70 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/opendis/record/base.py b/opendis/record/base.py index 2564839..ca70f9c 100644 --- a/opendis/record/base.py +++ b/opendis/record/base.py @@ -1,19 +1,17 @@ """Base classes for all Record types.""" -__all__ = ["Record", "VariableRecord"] +__all__ = ["Record", "StandardVariableRecord", "VariableRecord"] from abc import abstractmethod from typing import Any, Protocol, TypeGuard, runtime_checkable from opendis.stream import DataInputStream, DataOutputStream +from opendis.types import enum32 @runtime_checkable class Record(Protocol): - """Base class for all Record types. - - This base class defines the interface for DIS records with fixed sizes. - """ + """Base class for all Record types with fixed sizes.""" @abstractmethod def marshalledSize(self) -> int: @@ -30,10 +28,7 @@ def parse(self, inputStream: DataInputStream) -> None: @runtime_checkable class VariableRecord(Protocol): - """Base class for all Variable Record types. - - This base class defines the interface for DIS records with variable sizes. - """ + """Base class for all Record types with variable sizes.""" @staticmethod def is_positive_int(value: Any) -> TypeGuard[int]: @@ -51,8 +46,63 @@ def serialize(self, outputStream: DataOutputStream) -> None: @abstractmethod def parse(self, inputStream: DataInputStream, - bytelength: int | None = None) -> None: + bytelength: int) -> None: + """Parse the record from the input stream. + + If bytelength is provided, it indicates the expected length of the record. Some records may require this information to parse correctly. + """ + if not self.is_positive_int(bytelength): + raise ValueError( + f"bytelength must be a non-negative integer, got {bytelength!r}" + ) + # TODO: Implement padding handling + + +@runtime_checkable +class StandardVariableRecord(VariableRecord): + """6.2.83 Standard Variable (SV) Record + + This base class defines the interface for DIS records with variable sizes. + First SV record of a Standard Variable Specification record shall start on + a 64-bit boundary. + Padding shall be explicitly included in each record as necessary to make + the record length a multiple of 8 octets (64 bits) so that the following + record is automatically aligned. The record length requirement may be + achieved by placing padding fields anywhere in the SV record as deemed appropriate, not necessarily at the end of the record. + """ + recordType: enum32 # [UID 66] + + @staticmethod + def is_positive_int(value: Any) -> TypeGuard[int]: + """Check if a value is a positive integer.""" + return isinstance(value, int) and value >= 0 + + @property + def recordLength(self) -> int: + return self.marshalledSize() + + @abstractmethod + def serialize(self, outputStream: DataOutputStream) -> None: + """Serialize the record to the output stream.""" + super().serialize(outputStream) + outputStream.write_uint32(self.recordType) + outputStream.write_uint32(self.recordLength) + + @abstractmethod + def parse(self, + inputStream: DataInputStream, + bytelength: int) -> None: """Parse the record from the input stream. If bytelength is provided, it indicates the expected length of the record. Some records may require this information to parse correctly. + Assume that recordType and recordLength have already been read from the + stream. + recordLength will usually be passed to this method to assist in parsing. """ + # Validate bytelength + super().parse(inputStream, bytelength) + if not bytelength % 8 == 0: + raise ValueError( + f"bytelength must be a multiple of 8, got {bytelength!r}" + ) + # TODO: Implement padding handling From a929ac9265aec9e5d63a9c936046718d2220b049 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 04:20:56 +0000 Subject: [PATCH 22/37] refactor: follow VariableRecord and StandardVariableRecord interfaces subclasses must call superclass serialize() and parse() methods --- opendis/record/__init__.py | 190 +++++++++++-------------------------- 1 file changed, 53 insertions(+), 137 deletions(-) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 80b8d09..6258da8 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -23,7 +23,7 @@ uint32, ) -VR = TypeVar('VR', bound=base.VariableRecord) +SV = TypeVar('SV', bound=base.StandardVariableRecord) class Vector3Float(base.Record): @@ -335,27 +335,6 @@ class ModulationParametersRecord(base.VariableRecord): The total length of each record shall be a multiple of 64 bits. """ - @abstractmethod - def marshalledSize(self) -> int: - """Return the size (in bytes) 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, - bytelength: int | None = None) -> None: - """Parse the record from the input stream. - - If bytelength is provided, it indicates the expected length of the record. Some records may require this information to parse correctly. - """ - if not self.is_positive_int(bytelength): - raise ValueError( - f"bytelength must be a non-negative integer, got {bytelength!r}" - ) - class UnknownRadio(ModulationParametersRecord): """Placeholder for unknown or unimplemented radio types.""" @@ -367,14 +346,14 @@ def marshalledSize(self) -> int: return len(self.data) def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) outputStream.write_bytes(self.data) def parse(self, inputStream: DataInputStream, - bytelength: int | None = 0) -> None: + bytelength: int) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) - assert isinstance(bytelength, int) self.data = inputStream.read_bytes(bytelength) @@ -393,7 +372,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int | None = None) -> None: + bytelength: int) -> None: pass @@ -416,7 +395,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int | None = None) -> None: + bytelength: int) -> None: pass @@ -445,6 +424,7 @@ def marshalledSize(self) -> int: return 16 # bytes def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) self.net_id.serialize(outputStream) outputStream.write_uint16(self.mwod_index) outputStream.write_uint16(self.reserved16) @@ -455,7 +435,9 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int | None = None) -> None: + bytelength: int) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) self.net_id.parse(inputStream) self.mwod_index = inputStream.read_uint16() self.reserved16 = inputStream.read_uint16() @@ -489,6 +471,7 @@ def marshalledSize(self) -> int: return 16 # bytes def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) outputStream.write_uint16(self.fh_net_id) outputStream.write_uint16(self.hop_set_id) outputStream.write_uint16(self.lockout_set_id) @@ -500,7 +483,9 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int | None = None) -> None: + bytelength: int) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) self.fh_net_id = inputStream.read_uint16() self.hop_set_id = inputStream.read_uint16() self.lockout_set_id = inputStream.read_uint16() @@ -517,25 +502,6 @@ class AntennaPatternRecord(base.VariableRecord): The total length of each record shall be a multiple of 64 bits. """ - @abstractmethod - def marshalledSize(self) -> int: - """Return the size (in bytes) 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, - bytelength: int | None = None) -> None: - """Parse the record from the input stream. - - The recordType field is assumed to have been read, so as to identify - the type of Antenna Pattern record to be parsed, before this method is - called. - """ - class UnknownAntennaPattern(AntennaPatternRecord): """Placeholder for unknown or unimplemented antenna pattern types.""" @@ -547,15 +513,16 @@ def marshalledSize(self) -> int: return len(self.data) def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) outputStream.write_bytes(self.data) def parse(self, inputStream: DataInputStream, - bytelength: int | None = 0) -> None: + bytelength: int) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) - assert isinstance(bytelength, int) - self.data = inputStream.read_bytes(bytelength) + # Read the remaining bytes in the record + self.data = inputStream.read_bytes(bytelength - 6) class BeamAntennaPattern(AntennaPatternRecord): @@ -592,6 +559,7 @@ def marshalledSize(self) -> int: return 40 def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) self.beamDirection.serialize(outputStream) outputStream.write_float32(self.azimuthBeamwidth) outputStream.write_float32(self.elevationBeamwidth) @@ -605,7 +573,9 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int | None = None) -> None: + bytelength: int = 40) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) self.beamDirection.parse(inputStream) self.azimuthBeamwidth = inputStream.read_float32() self.elevationBeamwidth = inputStream.read_float32() @@ -618,45 +588,21 @@ def parse(self, self.padding3 = inputStream.read_uint32() -class VariableTransmitterParametersRecord(base.VariableRecord): +class VariableTransmitterParametersRecord(base.StandardVariableRecord): """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. + 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 (in bytes) 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, - bytelength: int | None = None) -> 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. - """ - if not self.is_positive_int(bytelength): - raise ValueError( - f"bytelength must be a non-negative integer, got {bytelength!r}" - ) 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 @@ -670,19 +616,20 @@ def recordLength(self) -> uint16: return self.marshalledSize() def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) outputStream.write_uint32(self.recordType) outputStream.write_uint16(self.recordLength) outputStream.write_bytes(self.data) def parse(self, inputStream: DataInputStream, - bytelength: int | None = 0) -> None: + bytelength: int) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) - assert isinstance(bytelength, int) self.recordType = inputStream.read_uint32() recordLength = inputStream.read_uint16() - self.data = inputStream.read_bytes(recordLength) + # Read the remaining bytes in the record + self.data = inputStream.read_bytes(recordLength - 6) class HighFidelityHAVEQUICKRadio(VariableTransmitterParametersRecord): @@ -711,14 +658,11 @@ def __init__(self, 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: + super().serialize(outputStream) outputStream.write_uint16(self.padding1) self.netId.serialize(outputStream) outputStream.write_uint8(self.todTransmitIndicator) @@ -733,7 +677,9 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int | None = None) -> None: + bytelength: int) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) self.padding1 = inputStream.read_uint16() self.netId.parse(inputStream) self.todTransmitIndicator = inputStream.read_uint8() @@ -747,7 +693,7 @@ def parse(self, self.wod6 = inputStream.read_uint32() -class DamageDescriptionRecord(base.VariableRecord): +class DamageDescriptionRecord(base.StandardVariableRecord): """6.2.15 Damage Description record Damage Description records shall use the Standard Variable record format of @@ -755,27 +701,6 @@ class DamageDescriptionRecord(base.VariableRecord): New Damage Description records may be defined at some future date as needed. """ - @abstractmethod - def marshalledSize(self) -> int: - """Return the size (in bytes) 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, - bytelength: int | None = None) -> None: - """Parse the record from the input stream. - - recordType and recordLength are assumed to have been read before this method is called. - """ - if not self.is_positive_int(bytelength): - raise ValueError( - f"bytelength must be a non-negative integer, got {bytelength!r}" - ) - class UnknownDamage(DamageDescriptionRecord): """Placeholder for unknown or unimplemented damage description types.""" @@ -787,24 +712,21 @@ def __init__(self, recordType: enum32 = 0, data: bytes = b''): def marshalledSize(self) -> int: return 6 + len(self.data) - @property - def recordLength(self) -> uint16: - return self.marshalledSize() - def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) outputStream.write_uint32(self.recordType) outputStream.write_uint16(self.recordLength) outputStream.write_bytes(self.data) def parse(self, inputStream: DataInputStream, - bytelength: int | None = 0) -> None: + bytelength: int) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) - assert isinstance(bytelength, int) self.recordType = inputStream.read_uint32() recordLength = inputStream.read_uint16() - self.data = inputStream.read_bytes(recordLength) + # Read the remaining bytes in the record + self.data = inputStream.read_bytes(recordLength - 6) class DirectedEnergyDamage(DamageDescriptionRecord): @@ -814,7 +736,6 @@ class DirectedEnergyDamage(DamageDescriptionRecord): damage based on a relative x, y, z location from the center of the entity. """ recordType: enum32 = 4500 # [UID 66] Variable Record Type - recordLength: uint16 = 40 # in bytes def __init__( self, @@ -836,8 +757,12 @@ def __init__( self.componentVisualSmokeColor = componentVisualSmokeColor self.fireEventID = fireEventID or EventIdentifier() self.padding2: uint16 = 0 + + def marshalledSize(self) -> int: + return 40 def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) outputStream.write_uint32(self.recordType) outputStream.write_uint16(self.recordLength) outputStream.write_uint16(self.padding) @@ -853,10 +778,9 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int | None = 0) -> None: + bytelength: int) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) - assert isinstance(bytelength, int) self.padding = inputStream.read_unsigned_short() self.damageLocation.parse(inputStream) self.damageDiameter = inputStream.read_float32() @@ -869,7 +793,7 @@ def parse(self, self.padding2 = inputStream.read_uint16() -class DirectedEnergyAreaAimpoint(base.VariableRecord): +class DirectedEnergyAreaAimpoint(DamageDescriptionRecord): """6.2.20.2 DE Area Aimpoint record Targeting information when the target of the directed energy weapon is an @@ -885,10 +809,6 @@ def __init__(self, self.beamAntennaPatterns: list["BeamAntennaPattern"] = beamAntennaPatterns or [] self.directedEnergyTargetEnergyDepositions: list["DirectedEnergyTargetEnergyDeposition"] = directedEnergyTargetEnergyDepositions or [] - @property - def recordLength(self) -> uint16: - return self.marshalledSize() - @property def beamAntennaPatternCount(self) -> uint16: return len(self.beamAntennaPatterns) @@ -906,6 +826,7 @@ def marshalledSize(self) -> int: return size def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) outputStream.write_uint32(self.recordType) outputStream.write_uint16(self.recordLength) outputStream.write_uint16(self.padding) @@ -921,11 +842,9 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int | None = 0) -> None: + bytelength: int) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) - assert isinstance(bytelength, int) - recordLength = inputStream.read_uint16() self.padding = inputStream.read_uint16() beamAntennaPatternCount = inputStream.read_uint16() directedEnergyTargetEnergyDepositionCount = inputStream.read_uint16() @@ -933,14 +852,13 @@ def parse(self, record = BeamAntennaPattern() record.parse(inputStream) self.beamAntennaPatterns.append(record) - - for idx in range(0, directedEnergyTargetEnergyDepositionCount): + for _ in range(0, directedEnergyTargetEnergyDepositionCount): record = DirectedEnergyTargetEnergyDeposition() record.parse(inputStream) self.directedEnergyTargetEnergyDepositions.append(record) -class DirectedEnergyPrecisionAimpoint(base.VariableRecord): +class DirectedEnergyPrecisionAimpoint(DamageDescriptionRecord): """6.2.20.3 DE Precision Aimpoint record Targeting information when the target of the directed energy weapon is not @@ -975,14 +893,11 @@ def __init__(self, self.peakIrradiance = peakIrradiance self.padding2: uint32 = 0 - @property - def recordLength(self) -> uint16: - return self.marshalledSize() - def marshalledSize(self) -> int: return 96 def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) outputStream.write_uint32(self.recordType) outputStream.write_uint16(self.recordLength) outputStream.write_uint16(self.padding) @@ -1001,13 +916,12 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int | None = 0) -> None: + bytelength: int) -> None: """recordType and recordLength are assumed to have been read before this method is called. """ # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) - assert isinstance(bytelength, int) self.padding = inputStream.read_uint16() self.targetSpotLocation.parse(inputStream) self.targetSpotEntityLocation.parse(inputStream) @@ -1043,24 +957,26 @@ def marshalledSize(self) -> int: return self.targetEntityID.marshalledSize() + 6 def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) self.targetEntityID.serialize(outputStream) outputStream.write_uint16(self.padding) outputStream.write_float32(self.peakIrradiance) def parse(self, inputStream: DataInputStream) -> None: + super().parse(inputStream) self.targetEntityID.parse(inputStream) self.padding = inputStream.read_uint16() self.peakIrradiance = inputStream.read_float32() -__variableRecordClasses: dict[int, type[base.VariableRecord]] = { +__variableRecordClasses: dict[int, type[base.StandardVariableRecord]] = { 3000: HighFidelityHAVEQUICKRadio, } def getVariableRecordClass( recordType: int, - expectedType: type[VR] = base.VariableRecord -) -> type[VR] | None: + expectedType: type[SV] = base.StandardVariableRecord +) -> type[SV] | None: if not isinstance(recordType, int) or recordType < 0: raise ValueError( f"recordType must be a non-negative integer, got {recordType!r}" From d1b708b4c667c7c9134e54a50e2ce785dc0005aa Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 04:22:20 +0000 Subject: [PATCH 23/37] refactor: rename SVClass getter function --- opendis/dis7.py | 4 ++-- opendis/record/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 2a9529a..2355e73 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -26,7 +26,7 @@ DamageDescriptionRecord, UnknownDamage, DirectedEnergyDamage, - getVariableRecordClass, + getSVClass, ) from .stream import DataInputStream, DataOutputStream from .types import ( @@ -5089,7 +5089,7 @@ def parse(self, inputStream: DataInputStream) -> None: for _ in range(0, variableTransmitterParameterCount): recordType = inputStream.read_uint32() recordLength = inputStream.read_uint16() - vtpClass = getVariableRecordClass( + vtpClass = getSVClass( recordType, expectedType=VariableTransmitterParametersRecord ) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 6258da8..bb8d069 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -973,7 +973,7 @@ def parse(self, inputStream: DataInputStream) -> None: 3000: HighFidelityHAVEQUICKRadio, } -def getVariableRecordClass( +def getSVClass( recordType: int, expectedType: type[SV] = base.StandardVariableRecord ) -> type[SV] | None: From a9b1604ccff1fde50707c009791e86b5a3269918 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 04:24:07 +0000 Subject: [PATCH 24/37] feat: handle Directed Energy records in DirectedEnergyFirePdu --- opendis/dis7.py | 101 ++++++++++++++++++------------------- opendis/record/__init__.py | 3 ++ 2 files changed, 51 insertions(+), 53 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 2355e73..7414913 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -6312,93 +6312,88 @@ def __init__(self, munitionType: "EntityType | None" = None, shotStartTime: "ClockTime | None" = None, cumulativeShotTime: float32 = 0.0, # in seconds - apertureEmitterLocation: Vector3Float | None = None, + apertureEmitterLocation: Vector3Float | None = None, # in meters apertureDiameter: float32 = 0.0, # in meters wavelength: float32 = 0.0, # in meters - peakIrradiance=0.0, pulseRepetitionFrequency: float32 = 0.0, # in Hz pulseWidth: float32 = 0, # in seconds flags: struct16 = 0, # [UID 313] pulseShape: enum8 = 0, # [UID 312] - dERecords: list | None = None): + dERecords: list[DamageDescriptionRecord] | None = None): super(DirectedEnergyFirePdu, self).__init__() # TODO: validate entity type? self.munitionType = munitionType or EntityType() - """Field shall identify the munition type enumeration for the DE weapon beam, Section 7.3.4""" self.shotStartTime = shotStartTime or ClockTime() - """Field shall indicate the simulation time at start of the shot, Section 7.3.4""" self.cumulativeShotTime = cumulativeShotTime - """Field shall indicate the current cumulative duration of the shot, Section 7.3.4""" self.apertureEmitterLocation = (apertureEmitterLocation or Vector3Float()) - """Field shall identify the location of the DE weapon aperture/emitter, Section 7.3.4""" self.apertureDiameter = apertureDiameter - """Field shall identify the beam diameter at the aperture/emitter, Section 7.3.4""" self.wavelength = wavelength - """Field shall identify the emissions wavelength in units of meters, Section 7.3.4""" - self.peakIrradiance = peakIrradiance - """Field shall identify the current peak irradiance of emissions in units of Watts per square meter, Section 7.3.4""" + self.padding1: uint32 = 0 self.pulseRepetitionFrequency = pulseRepetitionFrequency - """field shall identify the current pulse repetition frequency in units of cycles per second (Hertz), Section 7.3.4""" self.pulseWidth = pulseWidth - """field shall identify the pulse width emissions in units of seconds, Section 7.3.4""" self.flags = flags """16bit Boolean field shall contain various flags to indicate status information needed to process a DE, Section 7.3.4""" self.pulseShape = pulseShape - """Field shall identify the pulse shape and shall be represented as an 8-bit enumeration, Section 7.3.4""" - self.padding1: uint8 = 0 - self.padding2: uint32 = 0 - self.padding3: uint16 = 0 - self.dERecords = dERecords or [] - """Fields shall contain one or more DE records, records shall conform to the variable record format (Section6.2.82), Section 7.3.4""" + self.padding2: uint8 = 0 + self.padding3: uint32 = 0 + self.padding4: uint16 = 0 + self.dERecords: list[DamageDescriptionRecord] = dERecords or [] @property def numberOfDERecords(self) -> uint16: return len(self.dERecords) - def serialize(self, outputStream): - """serialize the class""" + def serialize(self, outputStream: DataOutputStream) -> None: super(DirectedEnergyFirePdu, self).serialize(outputStream) self.munitionType.serialize(outputStream) self.shotStartTime.serialize(outputStream) - outputStream.write_float(self.commulativeShotTime) + outputStream.write_float32(self.commulativeShotTime) self.apertureEmitterLocation.serialize(outputStream) - outputStream.write_float(self.apertureDiameter) - outputStream.write_float(self.wavelength) - outputStream.write_float(self.peakIrradiance) - outputStream.write_float(self.pulseRepetitionFrequency) - outputStream.write_int(self.pulseWidth) - outputStream.write_int(self.flags) - outputStream.write_byte(self.pulseShape) - outputStream.write_unsigned_byte(self.padding1) - outputStream.write_unsigned_int(self.padding2) - outputStream.write_unsigned_short(self.padding3) - outputStream.write_unsigned_short(self.numberOfDERecords) - for anObj in self.dERecords: - anObj.serialize(outputStream) + outputStream.write_float32(self.apertureDiameter) + outputStream.write_float32(self.wavelength) + outputStream.write_uint32(self.padding1) + outputStream.write_float32(self.pulseRepetitionFrequency) + outputStream.write_float32(self.pulseWidth) + outputStream.write_uint16(self.flags) + outputStream.write_uint8(self.pulseShape) + outputStream.write_uint8(self.padding2) + outputStream.write_uint32(self.padding3) + outputStream.write_uint16(self.padding4) + outputStream.write_uint16(self.numberOfDERecords) + for record in self.dERecords: + record.serialize(outputStream) - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" + def parse(self, inputStream: DataInputStream) -> None: super(DirectedEnergyFirePdu, self).parse(inputStream) self.munitionType.parse(inputStream) self.shotStartTime.parse(inputStream) - self.commulativeShotTime = inputStream.read_float() + self.commulativeShotTime = inputStream.read_float32() self.apertureEmitterLocation.parse(inputStream) - self.apertureDiameter = inputStream.read_float() - self.wavelength = inputStream.read_float() - self.peakIrradiance = inputStream.read_float() - self.pulseRepetitionFrequency = inputStream.read_float() - self.pulseWidth = inputStream.read_int() - self.flags = inputStream.read_int() - self.pulseShape = inputStream.read_byte() - self.padding1 = inputStream.read_unsigned_byte() - self.padding2 = inputStream.read_unsigned_int() - self.padding3 = inputStream.read_unsigned_short() - numberOfDERecords = inputStream.read_unsigned_short() - for idx in range(0, numberOfDERecords): - element = null() - element.parse(inputStream) - self.dERecords.append(element) + self.apertureDiameter = inputStream.read_float32() + self.wavelength = inputStream.read_float32() + self.padding1 = inputStream.read_uint32() + self.pulseRepetitionFrequency = inputStream.read_float32() + self.pulseWidth = inputStream.read_float32() + self.flags = inputStream.read_uint16() + self.pulseShape = inputStream.read_uint8() + self.padding2 = inputStream.read_uint8() + self.padding3 = inputStream.read_uint32() + self.padding4 = inputStream.read_uint16() + numberOfDERecords = inputStream.read_uint16() + for _ in range(0, numberOfDERecords): + recordType = inputStream.read_uint32() + recordLength = inputStream.read_uint16() + vtpClass = getSVClass( + recordType, + expectedType=DamageDescriptionRecord + ) + if vtpClass: + vtp = vtpClass() + else: + vtp = UnknownVariableTransmitterParameters(recordType) + vtp.parse(inputStream, bytelength=recordLength) + self.dERecords.append(vtp) class DetonationPdu(WarfareFamilyPdu): diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index bb8d069..f07021d 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -971,6 +971,9 @@ def parse(self, inputStream: DataInputStream) -> None: __variableRecordClasses: dict[int, type[base.StandardVariableRecord]] = { 3000: HighFidelityHAVEQUICKRadio, + 4000: DirectedEnergyPrecisionAimpoint, + 4001: DirectedEnergyAreaAimpoint, + 4500: DirectedEnergyDamage, } def getSVClass( From ed938933d8ee7160a69375083f96104a4ccb0656 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 07:54:33 +0000 Subject: [PATCH 25/37] fix: fix type annotations --- opendis/record/__init__.py | 14 +++++++------- opendis/record/base.py | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index f07021d..573ad79 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -351,7 +351,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int) -> None: + bytelength: int | None = None) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) self.data = inputStream.read_bytes(bytelength) @@ -372,7 +372,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int) -> None: + bytelength: int | None = None) -> None: pass @@ -395,7 +395,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int) -> None: + bytelength: int | None = None) -> None: pass @@ -435,7 +435,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int) -> None: + bytelength: int | None = None) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) self.net_id.parse(inputStream) @@ -483,7 +483,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int) -> None: + bytelength: int | None = None) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) self.fh_net_id = inputStream.read_uint16() @@ -516,7 +516,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: super().serialize(outputStream) outputStream.write_bytes(self.data) - def parse(self, + def parse(self, # pyright: ignore[reportIncompatibleMethodOverride] inputStream: DataInputStream, bytelength: int) -> None: # Validate bytelength argument by calling base method @@ -573,7 +573,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: def parse(self, inputStream: DataInputStream, - bytelength: int = 40) -> None: + bytelength: int | None = 40) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) self.beamDirection.parse(inputStream) diff --git a/opendis/record/base.py b/opendis/record/base.py index ca70f9c..d103d7d 100644 --- a/opendis/record/base.py +++ b/opendis/record/base.py @@ -46,12 +46,12 @@ def serialize(self, outputStream: DataOutputStream) -> None: @abstractmethod def parse(self, inputStream: DataInputStream, - bytelength: int) -> None: + bytelength: int | None = None) -> None: """Parse the record from the input stream. If bytelength is provided, it indicates the expected length of the record. Some records may require this information to parse correctly. """ - if not self.is_positive_int(bytelength): + if bytelength is not None and not self.is_positive_int(bytelength): raise ValueError( f"bytelength must be a non-negative integer, got {bytelength!r}" ) From c7d3c365632053c12b8aa18810aef5a17e5974ea Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 07:56:14 +0000 Subject: [PATCH 26/37] refactor: use common UnknownStandardVariableRecord for all unrecognised record types --- opendis/dis7.py | 12 +---- opendis/record/__init__.py | 99 +++++++++++++------------------------- 2 files changed, 36 insertions(+), 75 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 7414913..749717a 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -22,9 +22,7 @@ BasicHaveQuickMP, CCTTSincgarsMP, VariableTransmitterParametersRecord, - UnknownVariableTransmitterParameters, DamageDescriptionRecord, - UnknownDamage, DirectedEnergyDamage, getSVClass, ) @@ -5093,10 +5091,7 @@ def parse(self, inputStream: DataInputStream) -> None: recordType, expectedType=VariableTransmitterParametersRecord ) - if vtpClass: - vtp = vtpClass() - else: - vtp = UnknownVariableTransmitterParameters(recordType) + vtp = vtpClass() vtp.parse(inputStream, bytelength=recordLength) self.variableTransmitterParameters.append(vtp) @@ -6388,10 +6383,7 @@ def parse(self, inputStream: DataInputStream) -> None: recordType, expectedType=DamageDescriptionRecord ) - if vtpClass: - vtp = vtpClass() - else: - vtp = UnknownVariableTransmitterParameters(recordType) + vtp = vtpClass() vtp.parse(inputStream, bytelength=recordLength) self.dERecords.append(vtp) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 573ad79..e5d8b39 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -599,39 +599,6 @@ class VariableTransmitterParametersRecord(base.StandardVariableRecord): """ -class UnknownVariableTransmitterParameters(VariableTransmitterParametersRecord): - """Placeholder for unknown or unimplemented variable transmitter parameter - types. - """ - - 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: - super().serialize(outputStream) - outputStream.write_uint32(self.recordType) - outputStream.write_uint16(self.recordLength) - outputStream.write_bytes(self.data) - - def parse(self, - inputStream: DataInputStream, - bytelength: int) -> None: - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - self.recordType = inputStream.read_uint32() - recordLength = inputStream.read_uint16() - # Read the remaining bytes in the record - self.data = inputStream.read_bytes(recordLength - 6) - - class HighFidelityHAVEQUICKRadio(VariableTransmitterParametersRecord): """Annex C C4.2.3, Table C.4 — High Fidelity HAVE QUICK Radio record""" recordType: enum32 = 3000 @@ -702,33 +669,6 @@ class DamageDescriptionRecord(base.StandardVariableRecord): """ -class UnknownDamage(DamageDescriptionRecord): - """Placeholder for unknown or unimplemented damage description types.""" - - 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) - - def serialize(self, outputStream: DataOutputStream) -> None: - super().serialize(outputStream) - outputStream.write_uint32(self.recordType) - outputStream.write_uint16(self.recordLength) - outputStream.write_bytes(self.data) - - def parse(self, - inputStream: DataInputStream, - bytelength: int) -> None: - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - self.recordType = inputStream.read_uint32() - recordLength = inputStream.read_uint16() - # Read the remaining bytes in the record - self.data = inputStream.read_bytes(recordLength - 6) - - class DirectedEnergyDamage(DamageDescriptionRecord): """6.2.15.2 Directed Energy Damage Description record @@ -979,17 +919,46 @@ def parse(self, inputStream: DataInputStream) -> None: def getSVClass( recordType: int, expectedType: type[SV] = base.StandardVariableRecord -) -> type[SV] | None: +) -> type[SV]: + """Return a StandardVariableRecord subclass for the given recordType.""" + + # Declare a local class since the recordType class variable will need to be + # set for each new unrecognised record type. + class UnknownStandardVariableRecord(base.StandardVariableRecord): + """A placeholder class for unrecognised Standard Variable Records.""" + recordType: enum32 + + def __init__(self, data: bytes = b'') -> None: + self.data = data + + def marshalledSize(self) -> uint16: + return 6 + len(self.data) + + def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) + outputStream.write_uint32(self.recordType) + outputStream.write_uint16(self.recordLength) + outputStream.write_bytes(self.data) + + def parse(self, + inputStream: DataInputStream, + bytelength: int) -> None: + super().parse(inputStream, bytelength) + # Subtract 6 bytes for type and length + self.data = inputStream.read_bytes(bytelength - 6) + if not isinstance(recordType, int) or recordType < 0: raise ValueError( f"recordType must be a non-negative integer, got {recordType!r}" ) - vrClass = __variableRecordClasses.get(recordType, None) - if vrClass is None: - return None + UnknownStandardVariableRecord.recordType = recordType + vrClass = __variableRecordClasses.get( + recordType, + UnknownStandardVariableRecord + ) if not issubclass(vrClass, expectedType): raise TypeError( f"Record Type {recordType}: Record class {vrClass.__name__} is not " f"a subclass of {expectedType.__name__}" ) - return vrClass # type: ignore[return-value] + return vrClass From 2f659ef43a2115e70bdb58e713ca7167e1feb1f0 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 08:07:21 +0000 Subject: [PATCH 27/37] feat: handle DirectedEnergy fire flags in DirectedEnergyFirePdu --- opendis/dis7.py | 12 ++++++------ opendis/record/__init__.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 749717a..0680c72 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -12,6 +12,7 @@ UnknownAntennaPattern, EulerAngles, Vector3Float, + DEFireFlags, WorldCoordinates, EntityIdentifier, EventIdentifier, @@ -6312,7 +6313,7 @@ def __init__(self, wavelength: float32 = 0.0, # in meters pulseRepetitionFrequency: float32 = 0.0, # in Hz pulseWidth: float32 = 0, # in seconds - flags: struct16 = 0, # [UID 313] + flags: DEFireFlags | None = None, # [UID 313] pulseShape: enum8 = 0, # [UID 312] dERecords: list[DamageDescriptionRecord] | None = None): super(DirectedEnergyFirePdu, self).__init__() @@ -6327,8 +6328,7 @@ def __init__(self, self.padding1: uint32 = 0 self.pulseRepetitionFrequency = pulseRepetitionFrequency self.pulseWidth = pulseWidth - self.flags = flags - """16bit Boolean field shall contain various flags to indicate status information needed to process a DE, Section 7.3.4""" + self.flags = flags or DEFireFlags() self.pulseShape = pulseShape self.padding2: uint8 = 0 self.padding3: uint32 = 0 @@ -6350,7 +6350,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_uint32(self.padding1) outputStream.write_float32(self.pulseRepetitionFrequency) outputStream.write_float32(self.pulseWidth) - outputStream.write_uint16(self.flags) + self.flags.serialize(outputStream) outputStream.write_uint8(self.pulseShape) outputStream.write_uint8(self.padding2) outputStream.write_uint32(self.padding3) @@ -6370,7 +6370,7 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding1 = inputStream.read_uint32() self.pulseRepetitionFrequency = inputStream.read_float32() self.pulseWidth = inputStream.read_float32() - self.flags = inputStream.read_uint16() + self.flags.parse(inputStream) self.pulseShape = inputStream.read_uint8() self.padding2 = inputStream.read_uint8() self.padding3 = inputStream.read_uint32() @@ -6383,7 +6383,7 @@ def parse(self, inputStream: DataInputStream) -> None: recordType, expectedType=DamageDescriptionRecord ) - vtp = vtpClass() + vtp = vtpClass() # pyright: ignore[reportAbstractUsage] vtp.parse(inputStream, bytelength=recordLength) self.dERecords.append(vtp) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index e5d8b39..a188a8e 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -231,6 +231,39 @@ def parse(self, inputStream: DataInputStream) -> None: self.radioSystem = inputStream.read_uint16() +class DEFireFlags(base.Record): + """SISO-REF-010-2025 18.5.2 DE Fire Flags [UID 313]""" + + _struct = bitfield.bitfield(name="DEFireFlags", fields=[ + ("weaponOn", bitfield.INTEGER, 1), # state of the DE Weapon + ("stateUpdateFlag", bitfield.INTEGER, 1), # DE Weapon State Change + ("padding", bitfield.INTEGER, 14) # unused bits + ]) + + def __init__(self, + weaponOn: bool = False, + stateUpdateFlag: bf_enum = 0): # [UID 299] + # Net number ranging from 0 to 999 decimal + self.weaponOn = weaponOn + self.stateUpdateFlag = stateUpdateFlag + self.padding: bf_uint = 0 + + def marshalledSize(self) -> int: + return self._struct.marshalledSize() + + def serialize(self, outputStream: DataOutputStream) -> None: + self._struct( + self.weaponOn, + self.stateUpdateFlag, + ).serialize(outputStream) + + def parse(self, inputStream: DataInputStream) -> None: + record_bitfield = self._struct.parse(inputStream) + self.weaponOn = record_bitfield.weaponOn + self.stateUpdateFlag = record_bitfield.stateUpdateFlag + self.padding = record_bitfield.padding + + class NetId(base.Record): """Annex C, Table C.5 From de003fd01b42300537072fdbb25cae6387a1f0d9 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 08:07:38 +0000 Subject: [PATCH 28/37] chore: remove unused imports --- opendis/dis7.py | 1 - opendis/record/__init__.py | 1 - 2 files changed, 2 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 0680c72..2f7d21b 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -24,7 +24,6 @@ CCTTSincgarsMP, VariableTransmitterParametersRecord, DamageDescriptionRecord, - DirectedEnergyDamage, getSVClass, ) from .stream import DataInputStream, DataOutputStream diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index a188a8e..e6b67f4 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -3,7 +3,6 @@ This module defines classes for various record types used in DIS PDUs. """ -from abc import abstractmethod from typing import TypeVar from . import base, bitfield From 46aaf1ce8217a35c514587b9ec35c3dd12e7de37 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 08:08:58 +0000 Subject: [PATCH 29/37] fix: fix type annotation issues --- opendis/record/__init__.py | 1 + opendis/record/base.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index e6b67f4..65d4dd3 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -386,6 +386,7 @@ def parse(self, bytelength: int | None = None) -> None: # Validate bytelength argument by calling base method super().parse(inputStream, bytelength) + assert isinstance(bytelength, int) self.data = inputStream.read_bytes(bytelength) diff --git a/opendis/record/base.py b/opendis/record/base.py index d103d7d..416321f 100644 --- a/opendis/record/base.py +++ b/opendis/record/base.py @@ -89,7 +89,7 @@ def serialize(self, outputStream: DataOutputStream) -> None: outputStream.write_uint32(self.recordLength) @abstractmethod - def parse(self, + def parse(self, # pyright: ignore [reportIncompatibleMethodOverride] inputStream: DataInputStream, bytelength: int) -> None: """Parse the record from the input stream. From 0e49cf4b49d60abfc07bb4257436de2cd5f33613 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 08:47:32 +0000 Subject: [PATCH 30/37] refactor: organise radio namespace by protocol family --- opendis/record/__init__.py | 927 +---------------------------- opendis/record/common.py | 198 ++++++ opendis/record/radio/__init__.py | 507 ++++++++++++++++ opendis/record/symbolic_names.py | 136 +++++ opendis/record/warfare/__init__.py | 280 +++++++++ opendis/record/warfare/enums.py | 38 ++ 6 files changed, 1166 insertions(+), 920 deletions(-) create mode 100644 opendis/record/common.py create mode 100644 opendis/record/radio/__init__.py create mode 100644 opendis/record/symbolic_names.py create mode 100644 opendis/record/warfare/__init__.py create mode 100644 opendis/record/warfare/enums.py diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 65d4dd3..8b529f8 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -5,9 +5,8 @@ from typing import TypeVar -from . import base, bitfield -from ..stream import DataInputStream, DataOutputStream -from ..types import ( +from opendis.stream import DataInputStream, DataOutputStream +from opendis.types import ( enum8, enum16, enum32, @@ -22,924 +21,12 @@ uint32, ) -SV = TypeVar('SV', bound=base.StandardVariableRecord) - - -class Vector3Float(base.Record): - """6.2.96 Vector record - - Vector values for entity coordinates, linear acceleration, and linear - velocity shall be represented using a Vector record. This record shall - consist of three fields, each a 32-bit floating point number. - The unit of measure represented by these fields shall depend on the - information represented. - """ - - def __init__(self, x: float32 = 0.0, y: float32 = 0.0, z: float32 = 0.0): - self.x = x - self.y = y - self.z = z - - def marshalledSize(self) -> int: - return 12 - - def serialize(self, outputStream: DataOutputStream) -> None: - outputStream.write_float(self.x) - outputStream.write_float(self.y) - outputStream.write_float(self.z) - - def parse(self, inputStream: DataInputStream) -> None: - self.x = inputStream.read_float() - self.y = inputStream.read_float() - self.z = inputStream.read_float() - - -class WorldCoordinates(base.Record): - """6.2.98 World Coordinates record - - Location of the origin of the entity's or object's coordinate system, - target locations, detonation locations, and other points shall be specified - by a set of three coordinates: X, Y, and Z, represented by 64-bit floating - point numbers. - """ - - def __init__(self, x: float64 = 0.0, y: float64 = 0.0, z: float64 = 0.0): - self.x = x - self.y = y - self.z = z - - def marshalledSize(self) -> int: - return 24 - - def serialize(self, outputStream: DataOutputStream) -> None: - outputStream.write_float64(self.x) - outputStream.write_float64(self.y) - outputStream.write_float64(self.z) - - def parse(self, inputStream: DataInputStream) -> None: - self.x = inputStream.read_float64() - self.y = inputStream.read_float64() - self.z = inputStream.read_float64() - - -class EntityIdentifier(base.Record): - """Section 6.2.28 - - Entity Identifier. Unique ID for entities in the world. Consists of a - simulation address and a entity number. - """ - - def __init__(self, - simulationAddress: "SimulationAddress | None" = None, - entityNumber: uint16 = 0): - self.simulationAddress = simulationAddress or SimulationAddress() - self.entityNumber = entityNumber - - def marshalledSize(self) -> int: - return self.simulationAddress.marshalledSize() + 2 - - def serialize(self, outputStream: DataOutputStream) -> None: - self.simulationAddress.serialize(outputStream) - outputStream.write_uint16(self.entityNumber) - - def parse(self, inputStream: DataInputStream) -> None: - self.simulationAddress.parse(inputStream) - self.entityNumber = inputStream.read_uint16() - - -class EulerAngles(base.Record): - """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: DataOutputStream) -> None: - outputStream.write_float32(self.psi) - outputStream.write_float32(self.theta) - outputStream.write_float32(self.phi) - - def parse(self, inputStream: DataInputStream) -> None: - self.psi = inputStream.read_float32() - self.theta = inputStream.read_float32() - self.phi = inputStream.read_float32() - - -class SimulationAddress(base.Record): - """6.2.80 Simulation Address record - - Simulation designation associated with all object identifiers except - those contained in Live Entity PDUs. - """ - - def __init__(self, - site: uint16 = 0, - application: uint16 = 0): - self.site = site - """A site is defined as a facility, installation, organizational unit or a geographic location that has one or more simulation applications capable of participating in a distributed event.""" - self.application = application - """An application is defined as a software program that is used to generate and process distributed simulation data including live, virtual and constructive data.""" - - def marshalledSize(self) -> int: - return 4 - - def serialize(self, outputStream: DataOutputStream) -> None: - outputStream.write_unsigned_short(self.site) - outputStream.write_unsigned_short(self.application) - - def parse(self, inputStream: DataInputStream) -> None: - self.site = inputStream.read_unsigned_short() - self.application = inputStream.read_unsigned_short() - - -class EventIdentifier(base.Record): - """6.2.33 Event Identifier record - - Identifies an event in the world. Use this format for every PDU EXCEPT - the LiveEntityPdu. - """ - # TODO: Distinguish EventIdentifier and LiveEventIdentifier - - def __init__(self, - simulationAddress: "SimulationAddress | None" = None, - eventNumber: uint16 = 0): - self.simulationAddress = simulationAddress or SimulationAddress() - """Site and application IDs""" - self.eventNumber = eventNumber - - def marshalledSize(self) -> int: - return self.simulationAddress.marshalledSize() + 2 - - def serialize(self, outputStream: DataOutputStream) -> None: - self.simulationAddress.serialize(outputStream) - outputStream.write_unsigned_short(self.eventNumber) - - def parse(self, inputStream: DataInputStream) -> None: - self.simulationAddress.parse(inputStream) - self.eventNumber = inputStream.read_unsigned_short() - - -class ModulationType(base.Record): - """Section 6.2.59 - - Information about the type of modulation used for radio transmission. - """ - - def __init__(self, - spreadSpectrum: "SpreadSpectrum | None" = None, # See RPR Enumerations - majorModulation: enum16 = 0, # [UID 155] - detail: enum16 = 0, # [UID 156-162] - radioSystem: enum16 = 0): # [UID 163] - self.spreadSpectrum = spreadSpectrum or SpreadSpectrum() - """This field shall indicate the spread spectrum technique or combination of spread spectrum techniques in use. Bit field. 0=freq hopping, 1=psuedo noise, time hopping=2, remaining bits unused""" - self.majorModulation = majorModulation - self.detail = detail - self.radioSystem = radioSystem - - def marshalledSize(self) -> int: - size = 0 - size += self.spreadSpectrum.marshalledSize() - size += 2 # majorModulation - size += 2 # detail - size += 2 # radioSystem - return size - - def serialize(self, outputStream: DataOutputStream) -> None: - self.spreadSpectrum.serialize(outputStream) - outputStream.write_uint16(self.majorModulation) - outputStream.write_uint16(self.detail) - outputStream.write_uint16(self.radioSystem) - - def parse(self, inputStream: DataInputStream) -> None: - self.spreadSpectrum.parse(inputStream) - self.majorModulation = inputStream.read_uint16() - self.detail = inputStream.read_uint16() - self.radioSystem = inputStream.read_uint16() - - -class DEFireFlags(base.Record): - """SISO-REF-010-2025 18.5.2 DE Fire Flags [UID 313]""" - - _struct = bitfield.bitfield(name="DEFireFlags", fields=[ - ("weaponOn", bitfield.INTEGER, 1), # state of the DE Weapon - ("stateUpdateFlag", bitfield.INTEGER, 1), # DE Weapon State Change - ("padding", bitfield.INTEGER, 14) # unused bits - ]) - - def __init__(self, - weaponOn: bool = False, - stateUpdateFlag: bf_enum = 0): # [UID 299] - # Net number ranging from 0 to 999 decimal - self.weaponOn = weaponOn - self.stateUpdateFlag = stateUpdateFlag - self.padding: bf_uint = 0 - - def marshalledSize(self) -> int: - return self._struct.marshalledSize() - - def serialize(self, outputStream: DataOutputStream) -> None: - self._struct( - self.weaponOn, - self.stateUpdateFlag, - ).serialize(outputStream) - - def parse(self, inputStream: DataInputStream) -> None: - record_bitfield = self._struct.parse(inputStream) - self.weaponOn = record_bitfield.weaponOn - self.stateUpdateFlag = record_bitfield.stateUpdateFlag - self.padding = record_bitfield.padding - - -class NetId(base.Record): - """Annex C, Table C.5 - - Represents an Operational Net in the format of NXX.XYY, where: - N = Mode - XXX = Net Number - YY = Frequency Table - """ - - _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, - netNumber: bf_uint = 0, - frequencyTable: bf_enum = 0, # [UID 299] - mode: bf_enum = 0, # [UID 298] - padding: bf_uint = 0): - # Net number ranging from 0 to 999 decimal - self.netNumber = netNumber - self.frequencyTable = frequencyTable - self.mode = mode - self.padding = padding - - def marshalledSize(self) -> int: - return self._struct.marshalledSize() - - def serialize(self, outputStream: DataOutputStream) -> None: - self._struct( - self.netNumber, - self.frequencyTable, - self.mode, - self.padding - ).serialize(outputStream) - - def parse(self, inputStream: DataInputStream) -> None: - record_bitfield = self._struct.parse(inputStream) - self.netNumber = record_bitfield.netNumber - self.frequencyTable = record_bitfield.frequencyTable - self.mode = record_bitfield.mode - self.padding = record_bitfield.padding - - -class SpreadSpectrum(base.Record): - """6.2.59 Modulation Type Record, Table 90 - - Modulation used for radio transmission is characterized in a generic - fashion by the Spread Spectrum, Major Modulation, and Detail fields. - - Each independent type of spread spectrum technique shall be represented by - a single element of this array. - If a particular spread spectrum technique is in use, the corresponding array - element shall be set to one; otherwise it shall be set to zero. - All unused array elements shall be set to zero. - - In Python, the presence or absence of each technique is indicated by a bool. - """ - - _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, - frequencyHopping: bool = False, - pseudoNoise: bool = False, - timeHopping: bool = False, - padding: bf_uint = 0): - self.frequencyHopping = frequencyHopping - self.pseudoNoise = pseudoNoise - self.timeHopping = timeHopping - self.padding = padding - - def marshalledSize(self) -> int: - return self._struct.marshalledSize() - - def serialize(self, outputStream: DataOutputStream) -> None: - # Bitfield expects int input - self._struct( - int(self.frequencyHopping), - int(self.pseudoNoise), - int(self.timeHopping), - self.padding - ).serialize(outputStream) - - def parse(self, inputStream: DataInputStream) -> None: - record_bitfield = self._struct.parse(inputStream) - self.frequencyHopping = bool(record_bitfield.frequencyHopping) - self.pseudoNoise = bool(record_bitfield.pseudoNoise) - self.timeHopping = bool(record_bitfield.timeHopping) - - -class ModulationParametersRecord(base.VariableRecord): - """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. - """ - - -class UnknownRadio(ModulationParametersRecord): - """Placeholder for unknown or unimplemented radio types.""" - - def __init__(self, data: bytes = b''): - self.data = data - - def marshalledSize(self) -> int: - return len(self.data) +from . import base, bitfield, symbolic_names as sym +from .common import * +from .radio import * +from .warfare import * - def serialize(self, outputStream: DataOutputStream) -> None: - super().serialize(outputStream) - outputStream.write_bytes(self.data) - - def parse(self, - inputStream: DataInputStream, - bytelength: int | None = None) -> None: - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - assert isinstance(bytelength, int) - self.data = inputStream.read_bytes(bytelength) - - -class GenericRadio(ModulationParametersRecord): - """Annex C.2 Generic Radio record - - There are no other specific Transmitter, Signal, or Receiver PDU - requirements unique to a generic radio. - """ - - def marshalledSize(self) -> int: - return 0 - - def serialize(self, outputStream: DataOutputStream) -> None: - pass - - def parse(self, - inputStream: DataInputStream, - bytelength: int | None = None) -> None: - pass - - -class SimpleIntercomRadio(ModulationParametersRecord): - """Annex C.3 Simple Intercom Radio - - A Simple Intercom shall be identified by both the Transmitter PDU - Modulation Type record—Radio System field indicating a system type of - Generic Radio or Simple Intercom (1) and by the Modulation Type - record—Major Modulation field set to No Statement (0). - - This class has specific field requirements for the TransmitterPdu. - """ - - def marshalledSize(self) -> int: - return 0 - - def serialize(self, outputStream: DataOutputStream) -> None: - pass - - def parse(self, - inputStream: DataInputStream, - bytelength: int | None = None) -> None: - pass - - -# C.4 HAVE QUICK Radios - -class BasicHaveQuickMP(ModulationParametersRecord): - """Annex C 4.2.2, Table C.3 — Basic HAVE QUICK MP record""" - - def __init__(self, - net_id: NetId | None = None, - mwod_index: uint16 = 1, - reserved16: uint16 = 0, - reserved8_1: uint8 = 0, - reserved8_2: uint8 = 0, - time_of_day: uint32 = 0, - padding: uint32 = 0): - self.net_id = net_id or NetId() - self.mwod_index = mwod_index - self.reserved16 = reserved16 - self.reserved8_1 = reserved8_1 - self.reserved8_2 = reserved8_2 - self.time_of_day = time_of_day - self.padding = padding - - def marshalledSize(self) -> int: - return 16 # bytes - - def serialize(self, outputStream: DataOutputStream) -> None: - super().serialize(outputStream) - self.net_id.serialize(outputStream) - outputStream.write_uint16(self.mwod_index) - outputStream.write_uint16(self.reserved16) - outputStream.write_uint8(self.reserved8_1) - outputStream.write_uint8(self.reserved8_2) - outputStream.write_uint32(self.time_of_day) - outputStream.write_uint32(self.padding) - - def parse(self, - inputStream: DataInputStream, - bytelength: int | None = None) -> None: - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - self.net_id.parse(inputStream) - self.mwod_index = inputStream.read_uint16() - self.reserved16 = inputStream.read_uint16() - self.reserved8_1 = inputStream.read_uint8() - self.reserved8_2 = inputStream.read_uint8() - self.time_of_day = inputStream.read_uint32() - 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: - super().serialize(outputStream) - 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, - bytelength: int | None = None) -> None: - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - 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(base.VariableRecord): - """6.2.8 Antenna Pattern record - - The total length of each record shall be a multiple of 64 bits. - """ - - -class UnknownAntennaPattern(AntennaPatternRecord): - """Placeholder for unknown or unimplemented antenna pattern types.""" - - def __init__(self, data: bytes = b''): - self.data = data - - def marshalledSize(self) -> int: - return len(self.data) - - def serialize(self, outputStream: DataOutputStream) -> None: - super().serialize(outputStream) - outputStream.write_bytes(self.data) - - def parse(self, # pyright: ignore[reportIncompatibleMethodOverride] - inputStream: DataInputStream, - bytelength: int) -> None: - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - # Read the remaining bytes in the record - self.data = inputStream.read_bytes(bytelength - 6) - - -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: - super().serialize(outputStream) - 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, - bytelength: int | None = 40) -> None: - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - 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() - - -class VariableTransmitterParametersRecord(base.StandardVariableRecord): - """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. - """ - - -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 - - def marshalledSize(self) -> int: - return 40 - - def serialize(self, outputStream: DataOutputStream) -> None: - super().serialize(outputStream) - 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, - bytelength: int) -> None: - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - 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() - - -class DamageDescriptionRecord(base.StandardVariableRecord): - """6.2.15 Damage Description record - - Damage Description records shall use the Standard Variable record format of - the Standard Variable Specification record (see 6.2.83). - New Damage Description records may be defined at some future date as needed. - """ - - -class DirectedEnergyDamage(DamageDescriptionRecord): - """6.2.15.2 Directed Energy Damage Description record - - Damage sustained by an entity due to directed energy. Location of the - damage based on a relative x, y, z location from the center of the entity. - """ - recordType: enum32 = 4500 # [UID 66] Variable Record Type - - def __init__( - self, - damageLocation: Vector3Float | None = None, - damageDiameter: float32 = 0.0, # in metres - temperature: float32 = -273.15, # in degrees Celsius - componentIdentification: enum8 = 0, # [UID 314] - componentDamageStatus: enum8 = 0, # [UID 315] - componentVisualDamageStatus: struct8 = 0, # [UID 317] - componentVisualSmokeColor: enum8 = 0, # [UID 316] - fireEventID: EventIdentifier | None = None): - self.padding: uint16 = 0 - self.damageLocation = damageLocation or Vector3Float() - self.damageDiameter = damageDiameter - self.temperature = temperature - self.componentIdentification = componentIdentification - self.componentDamageStatus = componentDamageStatus - self.componentVisualDamageStatus = componentVisualDamageStatus - self.componentVisualSmokeColor = componentVisualSmokeColor - self.fireEventID = fireEventID or EventIdentifier() - self.padding2: uint16 = 0 - - def marshalledSize(self) -> int: - return 40 - - def serialize(self, outputStream: DataOutputStream) -> None: - super().serialize(outputStream) - outputStream.write_uint32(self.recordType) - outputStream.write_uint16(self.recordLength) - outputStream.write_uint16(self.padding) - self.damageLocation.serialize(outputStream) - outputStream.write_float32(self.damageDiameter) - outputStream.write_float32(self.temperature) - outputStream.write_uint8(self.componentIdentification) - outputStream.write_uint8(self.componentDamageStatus) - outputStream.write_uint8(self.componentVisualDamageStatus) - outputStream.write_uint8(self.componentVisualSmokeColor) - self.fireEventID.serialize(outputStream) - outputStream.write_uint16(self.padding2) - - def parse(self, - inputStream: DataInputStream, - bytelength: int) -> None: - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - self.padding = inputStream.read_unsigned_short() - self.damageLocation.parse(inputStream) - self.damageDiameter = inputStream.read_float32() - self.temperature = inputStream.read_float32() - self.componentIdentification = inputStream.read_uint8() - self.componentDamageStatus = inputStream.read_uint8() - self.componentVisualDamageStatus = inputStream.read_uint8() - self.componentVisualSmokeColor = inputStream.read_uint8() - self.fireEventID.parse(inputStream) - self.padding2 = inputStream.read_uint16() - - -class DirectedEnergyAreaAimpoint(DamageDescriptionRecord): - """6.2.20.2 DE Area Aimpoint record - - Targeting information when the target of the directed energy weapon is an - area. The area may or may not be associated with one or more target - entities. - """ - recordType: enum32 = 4001 # [UID 66] - - def __init__(self, - beamAntennaPatterns: list["BeamAntennaPattern"] | None = None, - directedEnergyTargetEnergyDepositions: list["DirectedEnergyTargetEnergyDeposition"] | None = None): - self.padding: uint16 = 0 - self.beamAntennaPatterns: list["BeamAntennaPattern"] = beamAntennaPatterns or [] - self.directedEnergyTargetEnergyDepositions: list["DirectedEnergyTargetEnergyDeposition"] = directedEnergyTargetEnergyDepositions or [] - - @property - def beamAntennaPatternCount(self) -> uint16: - return len(self.beamAntennaPatterns) - - @property - def directedEnergyTargetEnergyDepositionCount(self) -> uint16: - return len(self.directedEnergyTargetEnergyDepositions) - - def marshalledSize(self) -> int: - size = 8 # recordType, recordLength, padding - for record in self.beamAntennaPatterns: - size += record.marshalledSize() - for record in self.directedEnergyTargetEnergyDepositions: - size += record.marshalledSize() - return size - - def serialize(self, outputStream: DataOutputStream) -> None: - super().serialize(outputStream) - outputStream.write_uint32(self.recordType) - outputStream.write_uint16(self.recordLength) - outputStream.write_uint16(self.padding) - outputStream.write_uint16(self.beamAntennaPatternCount) - outputStream.write_uint16( - self.directedEnergyTargetEnergyDepositionCount - ) - for record in self.beamAntennaPatterns: - record.serialize(outputStream) - - for record in self.directedEnergyTargetEnergyDepositions: - record.serialize(outputStream) - - def parse(self, - inputStream: DataInputStream, - bytelength: int) -> None: - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - self.padding = inputStream.read_uint16() - beamAntennaPatternCount = inputStream.read_uint16() - directedEnergyTargetEnergyDepositionCount = inputStream.read_uint16() - for _ in range(0, beamAntennaPatternCount): - record = BeamAntennaPattern() - record.parse(inputStream) - self.beamAntennaPatterns.append(record) - for _ in range(0, directedEnergyTargetEnergyDepositionCount): - record = DirectedEnergyTargetEnergyDeposition() - record.parse(inputStream) - self.directedEnergyTargetEnergyDepositions.append(record) - - -class DirectedEnergyPrecisionAimpoint(DamageDescriptionRecord): - """6.2.20.3 DE Precision Aimpoint record - - Targeting information when the target of the directed energy weapon is not - an area but a specific target entity. Use of this record assumes that the DE - weapon would not fire unless a target is known and is currently tracked. - """ - recordType: enum32 = 4000 - - def __init__(self, - targetSpotLocation: WorldCoordinates | None = None, - targetSpotEntityLocation: Vector3Float | None = None, - targetSpotVelocity: Vector3Float | None = None, # in m/s - targetSpotAcceleration: Vector3Float | None = None, # in m/s^2 - targetEntityID: EntityIdentifier | None = None, - targetComponentID: enum8 = 0, # [UID 314] - beamSpotType: enum8 = 0, # [UID 311] - beamSpotCrossSectionSemiMajorAxis: float32 = 0.0, # in meters - beamSpotCrossSectionSemiMinorAxis: float32 = 0.0, # in meters - beamSpotCrossSectionOrientationAngle: float32 = 0.0, # in radians - peakIrradiance: float32 = 0.0): # in W/m^2 - self.padding: uint16 = 0 - self.targetSpotLocation = targetSpotLocation or WorldCoordinates() - self.targetSpotEntityLocation = targetSpotEntityLocation or Vector3Float() - self.targetSpotVelocity = targetSpotVelocity or Vector3Float() - self.targetSpotAcceleration = targetSpotAcceleration or Vector3Float() - self.targetEntityID = targetEntityID or EntityIdentifier() - self.targetComponentID = targetComponentID - self.beamSpotType = beamSpotType - self.beamSpotCrossSectionSemiMajorAxis = beamSpotCrossSectionSemiMajorAxis - self.beamSpotCrossSectionSemiMinorAxis = beamSpotCrossSectionSemiMinorAxis - self.beamSpotCrossSectionOrientationAngle = beamSpotCrossSectionOrientationAngle - self.peakIrradiance = peakIrradiance - self.padding2: uint32 = 0 - - def marshalledSize(self) -> int: - return 96 - - def serialize(self, outputStream: DataOutputStream) -> None: - super().serialize(outputStream) - outputStream.write_uint32(self.recordType) - outputStream.write_uint16(self.recordLength) - outputStream.write_uint16(self.padding) - self.targetSpotLocation.serialize(outputStream) - self.targetSpotEntityLocation.serialize(outputStream) - self.targetSpotVelocity.serialize(outputStream) - self.targetSpotAcceleration.serialize(outputStream) - self.targetEntityID.serialize(outputStream) - outputStream.write_uint8(self.targetComponentID) - outputStream.write_uint8(self.beamSpotType) - outputStream.write_float32(self.beamSpotCrossSectionSemiMajorAxis) - outputStream.write_float32(self.beamSpotCrossSectionSemiMinorAxis) - outputStream.write_float32(self.beamSpotCrossSectionOrientationAngle) - outputStream.write_float32(self.peakIrradiance) - outputStream.write_uint32(self.padding2) - - def parse(self, - inputStream: DataInputStream, - bytelength: int) -> None: - """recordType and recordLength are assumed to have been read before - this method is called. - """ - # Validate bytelength argument by calling base method - super().parse(inputStream, bytelength) - self.padding = inputStream.read_uint16() - self.targetSpotLocation.parse(inputStream) - self.targetSpotEntityLocation.parse(inputStream) - self.targetSpotVelocity.parse(inputStream) - self.targetSpotAcceleration.parse(inputStream) - self.targetEntityID.parse(inputStream) - self.targetComponentID = inputStream.read_uint8() - self.beamSpotType = inputStream.read_uint8() - self.beamSpotCrossSectionSemiMajorAxis = inputStream.read_float32() - self.beamSpotCrossSectionSemiMinorAxis = inputStream.read_float32() - self.beamSpotCrossSectionOrientationAngle = inputStream.read_float32() - self.peakIrradiance = inputStream.read_float32() - self.padding2 = inputStream.read_uint32() - - -class DirectedEnergyTargetEnergyDeposition(base.Record): - """6.2.20.4 DE Target Energy Deposition record - - Directed energy deposition properties for a target entity shall be - communicated using the DE Target Energy Deposition record. This record is - required to be included inside another DE record as it does not have a - record type. - """ - - def __init__(self, - targetEntityID: EntityIdentifier | None = None, - peakIrradiance: float32 = 0.0): # in W/m^2 - self.targetEntityID = targetEntityID or EntityIdentifier() - self.padding: uint16 = 0 - self.peakIrradiance = peakIrradiance - - def marshalledSize(self) -> int: - return self.targetEntityID.marshalledSize() + 6 - - def serialize(self, outputStream: DataOutputStream) -> None: - super().serialize(outputStream) - self.targetEntityID.serialize(outputStream) - outputStream.write_uint16(self.padding) - outputStream.write_float32(self.peakIrradiance) - - def parse(self, inputStream: DataInputStream) -> None: - super().parse(inputStream) - self.targetEntityID.parse(inputStream) - self.padding = inputStream.read_uint16() - self.peakIrradiance = inputStream.read_float32() +SV = TypeVar('SV', bound=base.StandardVariableRecord) __variableRecordClasses: dict[int, type[base.StandardVariableRecord]] = { diff --git a/opendis/record/common.py b/opendis/record/common.py new file mode 100644 index 0000000..3f35f87 --- /dev/null +++ b/opendis/record/common.py @@ -0,0 +1,198 @@ +"""Common record types used across multiple protocol families""" + +from opendis.record import base, bitfield +from opendis.stream import DataInputStream, DataOutputStream +from opendis.types import ( + enum8, + enum16, + enum32, + bf_enum, + bf_int, + bf_uint, + float32, + float64, + struct8, + uint8, + uint16, + uint32, +) + +from . import symbolic_names as sym + +__all__ = [ + "Vector3Float", + "WorldCoordinates", + "EntityIdentifier", + "EulerAngles", + "SimulationAddress", + "EventIdentifier", +] + + +class EntityIdentifier(base.Record): + """Section 6.2.28 Entity Identifier record + + Unique designation of each entity in an event or exercise that is not + contained in a Live Entity PDU. + """ + + def __init__(self, + simulationAddress: "SimulationAddress | None" = None, + entityNumber: uint16 = 0): + self.simulationAddress = simulationAddress or SimulationAddress() + self.entityNumber = entityNumber + + def marshalledSize(self) -> int: + return self.simulationAddress.marshalledSize() + 2 + + def serialize(self, outputStream: DataOutputStream) -> None: + self.simulationAddress.serialize(outputStream) + outputStream.write_uint16(self.entityNumber) + + def parse(self, inputStream: DataInputStream) -> None: + self.simulationAddress.parse(inputStream) + self.entityNumber = inputStream.read_uint16() + + +class EulerAngles(base.Record): + """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: DataOutputStream) -> None: + outputStream.write_float32(self.psi) + outputStream.write_float32(self.theta) + outputStream.write_float32(self.phi) + + def parse(self, inputStream: DataInputStream) -> None: + self.psi = inputStream.read_float32() + self.theta = inputStream.read_float32() + self.phi = inputStream.read_float32() + + +class EventIdentifier(base.Record): + """6.2.33 Event Identifier record + + Identifies an event in the world. Use this format for every PDU EXCEPT + the LiveEntityPdu. + """ + # TODO: Distinguish EventIdentifier and LiveEventIdentifier + + def __init__(self, + simulationAddress: "SimulationAddress | None" = None, + eventNumber: uint16 = 0): + self.simulationAddress = simulationAddress or SimulationAddress() + """Site and application IDs""" + self.eventNumber = eventNumber + + def marshalledSize(self) -> int: + return self.simulationAddress.marshalledSize() + 2 + + def serialize(self, outputStream: DataOutputStream) -> None: + self.simulationAddress.serialize(outputStream) + outputStream.write_unsigned_short(self.eventNumber) + + def parse(self, inputStream: DataInputStream) -> None: + self.simulationAddress.parse(inputStream) + self.eventNumber = inputStream.read_unsigned_short() + + +class SimulationAddress(base.Record): + """6.2.80 Simulation Address record + + Simulation designation associated with all object identifiers except + those contained in Live Entity PDUs. + """ + + def __init__(self, + site: uint16 = 0, + application: uint16 = 0): + self.site = site + """A site is defined as a facility, installation, organizational unit or a geographic location that has one or more simulation applications capable of participating in a distributed event.""" + self.application = application + """An application is defined as a software program that is used to generate and process distributed simulation data including live, virtual and constructive data.""" + + def marshalledSize(self) -> int: + return 4 + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_unsigned_short(self.site) + outputStream.write_unsigned_short(self.application) + + def parse(self, inputStream: DataInputStream) -> None: + self.site = inputStream.read_unsigned_short() + self.application = inputStream.read_unsigned_short() + + +class Vector3Float(base.Record): + """6.2.96 Vector record + + Vector values for entity coordinates, linear acceleration, and linear + velocity shall be represented using a Vector record. This record shall + consist of three fields, each a 32-bit floating point number. + The unit of measure represented by these fields shall depend on the + information represented. + """ + + def __init__(self, x: float32 = 0.0, y: float32 = 0.0, z: float32 = 0.0): + self.x = x + self.y = y + self.z = z + + def marshalledSize(self) -> int: + return 12 + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_float(self.x) + outputStream.write_float(self.y) + outputStream.write_float(self.z) + + def parse(self, inputStream: DataInputStream) -> None: + self.x = inputStream.read_float() + self.y = inputStream.read_float() + self.z = inputStream.read_float() + + +class WorldCoordinates(base.Record): + """6.2.98 World Coordinates record + + Location of the origin of the entity's or object's coordinate system, + target locations, detonation locations, and other points shall be specified + by a set of three coordinates: X, Y, and Z, represented by 64-bit floating + point numbers. + """ + + def __init__(self, x: float64 = 0.0, y: float64 = 0.0, z: float64 = 0.0): + self.x = x + self.y = y + self.z = z + + def marshalledSize(self) -> int: + return 24 + + def serialize(self, outputStream: DataOutputStream) -> None: + outputStream.write_float64(self.x) + outputStream.write_float64(self.y) + outputStream.write_float64(self.z) + + def parse(self, inputStream: DataInputStream) -> None: + self.x = inputStream.read_float64() + self.y = inputStream.read_float64() + self.z = inputStream.read_float64() + + diff --git a/opendis/record/radio/__init__.py b/opendis/record/radio/__init__.py new file mode 100644 index 0000000..1ba6516 --- /dev/null +++ b/opendis/record/radio/__init__.py @@ -0,0 +1,507 @@ +"""Radio Family PDU record types""" + +__all__ = [ + "BasicHaveQuickMP", + "CCTTSincgarsMP", + "GenericRadio", + "HighFidelityHAVEQUICKRadio", + "ModulationParametersRecord", + "ModulationType", + "NetId", + "SimpleIntercomRadio", + "SpreadSpectrum", + "UnknownRadio", + "VariableTransmitterParametersRecord", +] + +from opendis.record import base, bitfield +from opendis.record.common import * +from opendis.stream import DataInputStream, DataOutputStream +from opendis.types import ( + enum8, + enum16, + enum32, + bf_enum, + bf_int, + bf_uint, + float32, + float64, + struct8, + uint8, + uint16, + uint32, +) + + +# Interfaces + +class AntennaPatternRecord(base.VariableRecord): + """6.2.8 Antenna Pattern record + + The total length of each record shall be a multiple of 64 bits. + """ + + +class ModulationParametersRecord(base.VariableRecord): + """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. + """ + + +# Placeholders + +class UnknownRadio(ModulationParametersRecord): + """Placeholder for unknown or unimplemented radio types.""" + + def __init__(self, data: bytes = b''): + self.data = data + + def marshalledSize(self) -> int: + return len(self.data) + + def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) + outputStream.write_bytes(self.data) + + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + assert isinstance(bytelength, int) + self.data = inputStream.read_bytes(bytelength) + + +class UnknownAntennaPattern(AntennaPatternRecord): + """Placeholder for unknown or unimplemented antenna pattern types.""" + + def __init__(self, data: bytes = b''): + self.data = data + + def marshalledSize(self) -> int: + return len(self.data) + + def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) + outputStream.write_bytes(self.data) + + def parse(self, # pyright: ignore[reportIncompatibleMethodOverride] + inputStream: DataInputStream, + bytelength: int) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + # Read the remaining bytes in the record + self.data = inputStream.read_bytes(bytelength - 6) + + +# Implementations + +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: + super().serialize(outputStream) + 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, + bytelength: int | None = 40) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + 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() + + +class SpreadSpectrum(base.Record): + """6.2.59 Modulation Type record, Table 90 + + Modulation used for radio transmission is characterized in a generic + fashion by the Spread Spectrum, Major Modulation, and Detail fields. + + Each independent type of spread spectrum technique shall be represented by + a single element of this array. + If a particular spread spectrum technique is in use, the corresponding array + element shall be set to one; otherwise it shall be set to zero. + All unused array elements shall be set to zero. + + In Python, the presence or absence of each technique is indicated by a bool. + """ + + _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, + frequencyHopping: bool = False, + pseudoNoise: bool = False, + timeHopping: bool = False, + padding: bf_uint = 0): + self.frequencyHopping = frequencyHopping + self.pseudoNoise = pseudoNoise + self.timeHopping = timeHopping + self.padding = padding + + def marshalledSize(self) -> int: + return self._struct.marshalledSize() + + def serialize(self, outputStream: DataOutputStream) -> None: + # Bitfield expects int input + self._struct( + int(self.frequencyHopping), + int(self.pseudoNoise), + int(self.timeHopping), + self.padding + ).serialize(outputStream) + + def parse(self, inputStream: DataInputStream) -> None: + record_bitfield = self._struct.parse(inputStream) + self.frequencyHopping = bool(record_bitfield.frequencyHopping) + self.pseudoNoise = bool(record_bitfield.pseudoNoise) + self.timeHopping = bool(record_bitfield.timeHopping) + + +class ModulationType(base.Record): + """6.2.59 Modulation Type Record + + Information about the type of modulation used for radio transmission. + """ + + def __init__(self, + spreadSpectrum: SpreadSpectrum | None = None, # See RPR Enumerations + majorModulation: enum16 = 0, # [UID 155] + detail: enum16 = 0, # [UID 156-162] + radioSystem: enum16 = 0): # [UID 163] + self.spreadSpectrum = spreadSpectrum or SpreadSpectrum() + """This field shall indicate the spread spectrum technique or combination of spread spectrum techniques in use. Bit field. 0=freq hopping, 1=psuedo noise, time hopping=2, remaining bits unused""" + self.majorModulation = majorModulation + self.detail = detail + self.radioSystem = radioSystem + + def marshalledSize(self) -> int: + size = 0 + size += self.spreadSpectrum.marshalledSize() + size += 2 # majorModulation + size += 2 # detail + size += 2 # radioSystem + return size + + def serialize(self, outputStream: DataOutputStream) -> None: + self.spreadSpectrum.serialize(outputStream) + outputStream.write_uint16(self.majorModulation) + outputStream.write_uint16(self.detail) + outputStream.write_uint16(self.radioSystem) + + def parse(self, inputStream: DataInputStream) -> None: + self.spreadSpectrum.parse(inputStream) + self.majorModulation = inputStream.read_uint16() + self.detail = inputStream.read_uint16() + self.radioSystem = inputStream.read_uint16() + + +class VariableTransmitterParametersRecord(base.StandardVariableRecord): + """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. + """ + + +class NetId(base.Record): + """Annex C, Table C.5 + + Represents an Operational Net in the format of NXX.XYY, where: + N = Mode + XXX = Net Number + YY = Frequency Table + """ + + _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, + netNumber: bf_uint = 0, + frequencyTable: bf_enum = 0, # [UID 299] + mode: bf_enum = 0, # [UID 298] + padding: bf_uint = 0): + # Net number ranging from 0 to 999 decimal + self.netNumber = netNumber + self.frequencyTable = frequencyTable + self.mode = mode + self.padding = padding + + def marshalledSize(self) -> int: + return self._struct.marshalledSize() + + def serialize(self, outputStream: DataOutputStream) -> None: + self._struct( + self.netNumber, + self.frequencyTable, + self.mode, + self.padding + ).serialize(outputStream) + + def parse(self, inputStream: DataInputStream) -> None: + record_bitfield = self._struct.parse(inputStream) + self.netNumber = record_bitfield.netNumber + self.frequencyTable = record_bitfield.frequencyTable + self.mode = record_bitfield.mode + self.padding = record_bitfield.padding + + +class GenericRadio(ModulationParametersRecord): + """Annex C.2 Generic Radio record + + There are no other specific Transmitter, Signal, or Receiver PDU + requirements unique to a generic radio. + """ + + def marshalledSize(self) -> int: + return 0 + + def serialize(self, outputStream: DataOutputStream) -> None: + pass + + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: + pass + + +class SimpleIntercomRadio(ModulationParametersRecord): + """Annex C.3 Simple Intercom Radio + + A Simple Intercom shall be identified by both the Transmitter PDU + Modulation Type record—Radio System field indicating a system type of + Generic Radio or Simple Intercom (1) and by the Modulation Type + record—Major Modulation field set to No Statement (0). + + This class has specific field requirements for the TransmitterPdu. + """ + + def marshalledSize(self) -> int: + return 0 + + def serialize(self, outputStream: DataOutputStream) -> None: + pass + + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: + pass + + +# C.4 HAVE QUICK Radios + +class BasicHaveQuickMP(ModulationParametersRecord): + """Annex C 4.2.2, Table C.3 — Basic HAVE QUICK MP record""" + + def __init__(self, + net_id: NetId | None = None, + mwod_index: uint16 = 1, + reserved16: uint16 = 0, + reserved8_1: uint8 = 0, + reserved8_2: uint8 = 0, + time_of_day: uint32 = 0, + padding: uint32 = 0): + self.net_id = net_id or NetId() + self.mwod_index = mwod_index + self.reserved16 = reserved16 + self.reserved8_1 = reserved8_1 + self.reserved8_2 = reserved8_2 + self.time_of_day = time_of_day + self.padding = padding + + def marshalledSize(self) -> int: + return 16 # bytes + + def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) + self.net_id.serialize(outputStream) + outputStream.write_uint16(self.mwod_index) + outputStream.write_uint16(self.reserved16) + outputStream.write_uint8(self.reserved8_1) + outputStream.write_uint8(self.reserved8_2) + outputStream.write_uint32(self.time_of_day) + outputStream.write_uint32(self.padding) + + def parse(self, + inputStream: DataInputStream, + bytelength: int | None = None) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + self.net_id.parse(inputStream) + self.mwod_index = inputStream.read_uint16() + self.reserved16 = inputStream.read_uint16() + self.reserved8_1 = inputStream.read_uint8() + self.reserved8_2 = inputStream.read_uint8() + self.time_of_day = inputStream.read_uint32() + 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: + super().serialize(outputStream) + 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, + bytelength: int | None = None) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + 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 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 + + def marshalledSize(self) -> int: + return 40 + + def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) + 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, + bytelength: int) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + 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() diff --git a/opendis/record/symbolic_names.py b/opendis/record/symbolic_names.py new file mode 100644 index 0000000..8cf858a --- /dev/null +++ b/opendis/record/symbolic_names.py @@ -0,0 +1,136 @@ +"""6.1.8 Table 25: Constants for DIS7 symbolic names""" + +AGG_RESPONSE_DFLT = 10 # Default: 10 s +ALL_AGGS = 0xFFFF # Hexadecimal +ALL_APPLIC = 0xFFFF # Hexadecimal +ALL_BEAMS = 0xFF # Hexadecimal +ALL_EMITTERS = 0xFF # Hexadecimal +ALL_ENTITIES = 0xFFFF # Hexadecimal +ALL_OBJECTS = 0xFFFF # Hexadecimal +ALL_SITES = 0xFFFF # Hexadecimal +COLLISION_ELASTIC_TIMEOUT = 5 # Default: 5 s +COLLISION_THRSH = 0.1 # Default: 0.1 m/s +DE_AREA_AIMING_THRSH = 10 # Default: 10° +DE_ENERGY_THRSH = 1.0 # Default: 1.0% +DE_PRECISION_AIMING_THRSH = 0.5 # Default: 0.5 m +DRA_ORIENT_THRSH = 3 # Default: 3° +DRA_POS_THRSH = 1 # Default: 1 m +D_SPOT_NO_ENTITY = None # No entity +EE_AD_PULRAT_THRSH = 0.017 # Default: 0.017 rad/s +EE_AD_PULACC_THRSH = 0.017 # Default: 0.017 rad/s² +EE_AZ_THRSH = 1 # Default: 1° +EE_EL_THRSH = 1 # Default: 1° +EE_ERP_THRSH = 1.0 # Default: 1.0 dBm +EE_FREQ_THRSH = 1 # Default: 1 Hz +EE_FRNG_THRSH = 1 # Default: 1 Hz +EE_FT_VEL_THRSH = 1.0 # Default: 1.0 m/s +EE_FT_ACC_THRSH = 1.0 # Default: 1.0 m/s² +EE_FT_MWD_THRSH = 10000 # Default: 10000 m +EE_FT_KT_THRSH = 10 # Default: 10 s +EE_FT_ESP_THRSH = 10 # Default: 10 m +EE_HIGH_DENSITY_THRSH = 10 # Default: 10 entities/beam +EE_PRF_THRSH = 1 # Default: 1 Hz +EE_PW_THRSH = 1 # Default: 1 +ENTITY_ID_UNKNOWN = None # No entity +EP_DIMENSION_THRSH = 1 # Default: 1 m +EP_NO_SEQUENCE = 0xFFFF # Hexadecimal +EP_POS_THRSH = 1 # Default: 1 m shift +EP_STATE_THRSH = 10 # Default: ±10% +GD_GEOMETRY_CHANGE = 10 # Default: ±10% +GD_STATE_CHANGE = 10 # Default: ±10% +HBT_DAMAGE_TIMEOUT_MPLIER = 2.4 # Default: 2.4 +HBT_ESPDU_KIND_CULTURAL_FEATURE = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_KIND_ENVIRONMENTAL = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_KIND_EXPENDABLE = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_KIND_LIFE_FORM = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_KIND_MUNITION = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_KIND_RADIO = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_KIND_SENSOR_EMITTER = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_KIND_SUPPLY = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_PLATFORM_AIR = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_PLATFORM_LAND = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_PLATFORM_SPACE = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_PLATFORM_SUBSURFACE = 5 # Default: 5 s, Tolerance: ±10% +HBT_ESPDU_PLATFORM_SURFACE = 5 # Default: 5 s, Tolerance: ±10% +HBT_PDU_AGGREGATE_STATE = 30 # Default: 30 s, Tolerance: ±10% +HBT_PDU_APPEARANCE = 60 # Default: 60 s, Tolerance: ±10% +HBT_PDU_DE_FIRE = 0.5 # Default: 0.5 s, Tolerance: ±10% +HBT_PDU_DESIGNATOR = 5 # Default: 5 s, Tolerance: ±10% +HBT_PDU_EE = 5 # Default: 5 s, Tolerance: ±10% +HBT_PDU_ENTITY_DAMAGE = 10 # Default: 10 s, Tolerance: ±10% +HBT_PDU_ENVIRONMENTAL_PROCESS = 15 # Default: 15 s, Tolerance: ±10% +HBT_PDU_GRIDDED_DATA = 900 # Default: 15 min, Tolerance: ±10% +HBT_PDU_IFF = 10 # Default: 10 s, Tolerance: ±10% +HBT_PDU_ISGROUPOF = 5 # Default: 5 s, Tolerance: ±10% +HBT_PDU_MINEFIELD_DATA = 5 # Default: 5 s, Tolerance: ±10% +HBT_PDU_MINEFIELD_STATE = 5 # Default: 5 s, Tolerance: ±10% +HBT_PDU_RECEIVER = 5 # Default: 5 s, Tolerance: ±10% +HBT_PDU_SEES = 180 # Default: 3 min, Tolerance: ±10% +HBT_PDU_TRANSMITTER = 2 # Default: 2 s, Tolerance: ±10% +HBT_PDU_TSPI = 30 # Default: 30 s, Tolerance: ±10% +HBT_PDU_UA = 180 # Default: 3 min, Tolerance: ±10% +HBT_STATIONARY = 60 # Default: 1 min, Tolerance: ±10% +HBT_TIMEOUT_MPLIER = 2.4 # Default: 2.4 +HQ_TOD_DIFF_THRSH = 20 # Default: 20 ms +IFF_AZ_THRSH = 3 # Default: 3° +IFF_CHG_LATENCY = 2 # Default: 2 s +IFF_EL_THRSH = 3 # Default: 3° +IFF_IP_REPLY_TIMER = 30 # Default: 30 s +IFF_PDU_FINAL = 10 # Default: 10 s +IFF_PDU_RESUME = 10 # Default: 10 s +IO_UNTIL_FURTHER_NOTICE = 65535 # Fixed +MAX_PDU_SIZE_BITS = 65536 # Fixed +MAX_PDU_SIZE_OCTETS = 8192 # Fixed +MINEFIELD_CHANGE = 2.5 # Default: 2.5 s +MINEFIELD_RESPONSE_TIMER = 1 # Default: 1 s +MULTIPLES_PRESENT = 0 # Fixed +NO_AGG = 0 # Fixed +NO_APPLIC = 0 # Fixed +NO_BEAM = 0 # Fixed +NO_CATEGORY = 0 # Fixed +NO_EMITTER = 0 # Fixed +NO_ENTITY = 0 # Fixed +NO_FIRE_MISSION = 0 # Fixed +NO_KIND = 0 # Fixed +NO_OBJECT = 0 # Fixed +NO_PATTERN = 0.0 # Fixed +NO_REF_NUMBER = 0 # Fixed +NO_SITE = 0 # Fixed +NO_SPECIFIC = 0 # Fixed +NO_SPECIFIC_ENTITY = None # No entity +NO_SUBCAT = 0 # Fixed +NO_VALUE = 0 # Fixed +NON_SYNC_THRSH = 60 # Default: 1 min +POWER_ENGINE_OFF = -100.0 # Fixed +POWER_IDLE = 0.0 # Fixed +POWER_MAX_AFTERBURNER = 100.0 # Fixed +POWER_MILITARY = 50.0 # Fixed +POWER_MIN_AFTERBURNER = 51.0 # Fixed +REPAR_REC_T1 = 5 # Default: 5 s +REPAR_SUP_T1 = 12 # Default: 12 s +REPAR_SUP_T2 = 12 # Default: 12 s +RESUP_REC_T1 = 5 # Default: 5 s +RESUP_REC_T2 = 55 # Default: 55 s +RESUP_SUP_T1 = 60 # Default: 1 min +RQST_ASSIGN_ID = 0xFFFE # Hexadecimal +SEES_NDA_THRSH = 2 # Default: ±2° in the axis of deflection +SEES_PS_THRSH = 10 # Default: ±10% of the maximum value of the Power Setting +SEES_RPM_THRSH = 5 # Default: ±5% of the maximum engine speed in RPM +SMALLEST_MTU_OCTETS = 1400 # Default: 1400 octets for IPv4 networks +SM_REL_RETRY_CNT = 3 # Default: 3 +SM_REL_RETRY_DELAY = 2 # Default: 2 s +TARGET_ID_UNKNOWN = None # No entity +TIMESTAMP_AHEAD = 5 # Default: 5 s +TIMESTAMP_BEHIND = 5 # Default: 5 s +TI_TIMER1_DFLT = 2 # Default: 2 s +TI_TIMER2_DFLT = 12 # Default: 12 s +TO_AUTO_RESPONSE_TIMER = 5 # Default: 5 s +TO_MAN_RESPONSE_TIMER = 120 # Default: 120 s +TR_TIMER1_DFLT = 5 # Default: 5 s +TR_TIMER2_DFLT = 60 # Default: 60 s +TRANS_ORIENT_THRSH = 180 # Default: 180° +TRANS_POS_THRSH = 500 # Default: 500 m +UA_ORIENT_THRSH = 2 # Default: 2° +UA_POS_THRSH = 10 # Default: 10 m +UA_SRPM_ROC_THRSH = 10 # Default: ±10% of maximum rate of change +UA_SRPM_THRSH = 5 # Default: ±5% of maximum shaft rate in RPM diff --git a/opendis/record/warfare/__init__.py b/opendis/record/warfare/__init__.py new file mode 100644 index 0000000..d0ea1dd --- /dev/null +++ b/opendis/record/warfare/__init__.py @@ -0,0 +1,280 @@ +"""Warfare Family PDU record types""" + +__all__ = [ + "DEFireFlags", + "DamageDescriptionRecord", + "DirectedEnergyDamage", + "DirectedEnergyAreaAimpoint", + "DirectedEnergyPrecisionAimpoint", + "DirectedEnergyTargetEnergyDeposition", +] + +from opendis.record import base, bitfield +from opendis.record.common import * +from opendis.stream import DataInputStream, DataOutputStream +from opendis.types import ( + enum8, + enum16, + enum32, + bf_enum, + bf_int, + bf_uint, + float32, + float64, + struct8, + uint8, + uint16, + uint32, +) + +from .enums import DEFireFlags +from opendis.record.radio import BeamAntennaPattern + + +class DamageDescriptionRecord(base.StandardVariableRecord): + """6.2.15 Damage Description record + + Damage Description records shall use the Standard Variable record format of + the Standard Variable Specification record (see 6.2.83). + New Damage Description records may be defined at some future date as needed. + """ + + +class DirectedEnergyDamage(DamageDescriptionRecord): + """6.2.15.2 Directed Energy Damage Description record + + Damage sustained by an entity due to directed energy. Location of the + damage based on a relative x, y, z location from the center of the entity. + """ + recordType: enum32 = 4500 # [UID 66] Variable Record Type + + def __init__( + self, + damageLocation: Vector3Float | None = None, + damageDiameter: float32 = 0.0, # in metres + temperature: float32 = -273.15, # in degrees Celsius + componentIdentification: enum8 = 0, # [UID 314] + componentDamageStatus: enum8 = 0, # [UID 315] + componentVisualDamageStatus: struct8 = 0, # [UID 317] + componentVisualSmokeColor: enum8 = 0, # [UID 316] + fireEventID: EventIdentifier | None = None): + self.padding: uint16 = 0 + self.damageLocation = damageLocation or Vector3Float() + self.damageDiameter = damageDiameter + self.temperature = temperature + self.componentIdentification = componentIdentification + self.componentDamageStatus = componentDamageStatus + self.componentVisualDamageStatus = componentVisualDamageStatus + self.componentVisualSmokeColor = componentVisualSmokeColor + self.fireEventID = fireEventID or EventIdentifier() + self.padding2: uint16 = 0 + + def marshalledSize(self) -> int: + return 40 + + def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) + outputStream.write_uint32(self.recordType) + outputStream.write_uint16(self.recordLength) + outputStream.write_uint16(self.padding) + self.damageLocation.serialize(outputStream) + outputStream.write_float32(self.damageDiameter) + outputStream.write_float32(self.temperature) + outputStream.write_uint8(self.componentIdentification) + outputStream.write_uint8(self.componentDamageStatus) + outputStream.write_uint8(self.componentVisualDamageStatus) + outputStream.write_uint8(self.componentVisualSmokeColor) + self.fireEventID.serialize(outputStream) + outputStream.write_uint16(self.padding2) + + def parse(self, + inputStream: DataInputStream, + bytelength: int) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + self.padding = inputStream.read_unsigned_short() + self.damageLocation.parse(inputStream) + self.damageDiameter = inputStream.read_float32() + self.temperature = inputStream.read_float32() + self.componentIdentification = inputStream.read_uint8() + self.componentDamageStatus = inputStream.read_uint8() + self.componentVisualDamageStatus = inputStream.read_uint8() + self.componentVisualSmokeColor = inputStream.read_uint8() + self.fireEventID.parse(inputStream) + self.padding2 = inputStream.read_uint16() + + +class DirectedEnergyAreaAimpoint(DamageDescriptionRecord): + """6.2.20.2 DE Area Aimpoint record + + Targeting information when the target of the directed energy weapon is an + area. The area may or may not be associated with one or more target + entities. + """ + recordType: enum32 = 4001 # [UID 66] + + def __init__(self, + beamAntennaPatterns: list["BeamAntennaPattern"] | None = None, + directedEnergyTargetEnergyDepositions: list["DirectedEnergyTargetEnergyDeposition"] | None = None): + self.padding: uint16 = 0 + self.beamAntennaPatterns: list["BeamAntennaPattern"] = beamAntennaPatterns or [] + self.directedEnergyTargetEnergyDepositions: list["DirectedEnergyTargetEnergyDeposition"] = directedEnergyTargetEnergyDepositions or [] + + @property + def beamAntennaPatternCount(self) -> uint16: + return len(self.beamAntennaPatterns) + + @property + def directedEnergyTargetEnergyDepositionCount(self) -> uint16: + return len(self.directedEnergyTargetEnergyDepositions) + + def marshalledSize(self) -> int: + size = 8 # recordType, recordLength, padding + for record in self.beamAntennaPatterns: + size += record.marshalledSize() + for record in self.directedEnergyTargetEnergyDepositions: + size += record.marshalledSize() + return size + + def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) + outputStream.write_uint32(self.recordType) + outputStream.write_uint16(self.recordLength) + outputStream.write_uint16(self.padding) + outputStream.write_uint16(self.beamAntennaPatternCount) + outputStream.write_uint16( + self.directedEnergyTargetEnergyDepositionCount + ) + for record in self.beamAntennaPatterns: + record.serialize(outputStream) + + for record in self.directedEnergyTargetEnergyDepositions: + record.serialize(outputStream) + + def parse(self, + inputStream: DataInputStream, + bytelength: int) -> None: + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + self.padding = inputStream.read_uint16() + beamAntennaPatternCount = inputStream.read_uint16() + directedEnergyTargetEnergyDepositionCount = inputStream.read_uint16() + for _ in range(0, beamAntennaPatternCount): + record = BeamAntennaPattern() + record.parse(inputStream) + self.beamAntennaPatterns.append(record) + for _ in range(0, directedEnergyTargetEnergyDepositionCount): + record = DirectedEnergyTargetEnergyDeposition() + record.parse(inputStream) + self.directedEnergyTargetEnergyDepositions.append(record) + + +class DirectedEnergyPrecisionAimpoint(DamageDescriptionRecord): + """6.2.20.3 DE Precision Aimpoint record + + Targeting information when the target of the directed energy weapon is not + an area but a specific target entity. Use of this record assumes that the DE + weapon would not fire unless a target is known and is currently tracked. + """ + recordType: enum32 = 4000 + + def __init__(self, + targetSpotLocation: WorldCoordinates | None = None, + targetSpotEntityLocation: Vector3Float | None = None, + targetSpotVelocity: Vector3Float | None = None, # in m/s + targetSpotAcceleration: Vector3Float | None = None, # in m/s^2 + targetEntityID: EntityIdentifier | None = None, + targetComponentID: enum8 = 0, # [UID 314] + beamSpotType: enum8 = 0, # [UID 311] + beamSpotCrossSectionSemiMajorAxis: float32 = 0.0, # in meters + beamSpotCrossSectionSemiMinorAxis: float32 = 0.0, # in meters + beamSpotCrossSectionOrientationAngle: float32 = 0.0, # in radians + peakIrradiance: float32 = 0.0): # in W/m^2 + self.padding: uint16 = 0 + self.targetSpotLocation = targetSpotLocation or WorldCoordinates() + self.targetSpotEntityLocation = targetSpotEntityLocation or Vector3Float() + self.targetSpotVelocity = targetSpotVelocity or Vector3Float() + self.targetSpotAcceleration = targetSpotAcceleration or Vector3Float() + self.targetEntityID = targetEntityID or EntityIdentifier() + self.targetComponentID = targetComponentID + self.beamSpotType = beamSpotType + self.beamSpotCrossSectionSemiMajorAxis = beamSpotCrossSectionSemiMajorAxis + self.beamSpotCrossSectionSemiMinorAxis = beamSpotCrossSectionSemiMinorAxis + self.beamSpotCrossSectionOrientationAngle = beamSpotCrossSectionOrientationAngle + self.peakIrradiance = peakIrradiance + self.padding2: uint32 = 0 + + def marshalledSize(self) -> int: + return 96 + + def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) + outputStream.write_uint32(self.recordType) + outputStream.write_uint16(self.recordLength) + outputStream.write_uint16(self.padding) + self.targetSpotLocation.serialize(outputStream) + self.targetSpotEntityLocation.serialize(outputStream) + self.targetSpotVelocity.serialize(outputStream) + self.targetSpotAcceleration.serialize(outputStream) + self.targetEntityID.serialize(outputStream) + outputStream.write_uint8(self.targetComponentID) + outputStream.write_uint8(self.beamSpotType) + outputStream.write_float32(self.beamSpotCrossSectionSemiMajorAxis) + outputStream.write_float32(self.beamSpotCrossSectionSemiMinorAxis) + outputStream.write_float32(self.beamSpotCrossSectionOrientationAngle) + outputStream.write_float32(self.peakIrradiance) + outputStream.write_uint32(self.padding2) + + def parse(self, + inputStream: DataInputStream, + bytelength: int) -> None: + """recordType and recordLength are assumed to have been read before + this method is called. + """ + # Validate bytelength argument by calling base method + super().parse(inputStream, bytelength) + self.padding = inputStream.read_uint16() + self.targetSpotLocation.parse(inputStream) + self.targetSpotEntityLocation.parse(inputStream) + self.targetSpotVelocity.parse(inputStream) + self.targetSpotAcceleration.parse(inputStream) + self.targetEntityID.parse(inputStream) + self.targetComponentID = inputStream.read_uint8() + self.beamSpotType = inputStream.read_uint8() + self.beamSpotCrossSectionSemiMajorAxis = inputStream.read_float32() + self.beamSpotCrossSectionSemiMinorAxis = inputStream.read_float32() + self.beamSpotCrossSectionOrientationAngle = inputStream.read_float32() + self.peakIrradiance = inputStream.read_float32() + self.padding2 = inputStream.read_uint32() + + +class DirectedEnergyTargetEnergyDeposition(base.Record): + """6.2.20.4 DE Target Energy Deposition record + + Directed energy deposition properties for a target entity shall be + communicated using the DE Target Energy Deposition record. This record is + required to be included inside another DE record as it does not have a + record type. + """ + + def __init__(self, + targetEntityID: EntityIdentifier | None = None, + peakIrradiance: float32 = 0.0): # in W/m^2 + self.targetEntityID = targetEntityID or EntityIdentifier() + self.padding: uint16 = 0 + self.peakIrradiance = peakIrradiance + + def marshalledSize(self) -> int: + return self.targetEntityID.marshalledSize() + 6 + + def serialize(self, outputStream: DataOutputStream) -> None: + super().serialize(outputStream) + self.targetEntityID.serialize(outputStream) + outputStream.write_uint16(self.padding) + outputStream.write_float32(self.peakIrradiance) + + def parse(self, inputStream: DataInputStream) -> None: + super().parse(inputStream) + self.targetEntityID.parse(inputStream) + self.padding = inputStream.read_uint16() + self.peakIrradiance = inputStream.read_float32() diff --git a/opendis/record/warfare/enums.py b/opendis/record/warfare/enums.py new file mode 100644 index 0000000..01d188a --- /dev/null +++ b/opendis/record/warfare/enums.py @@ -0,0 +1,38 @@ +"""Warfare Family PDU record types: SISO enumeration classes""" + +from opendis.record import base, bitfield +from opendis.stream import DataInputStream, DataOutputStream +from opendis.types import bf_enum, bf_uint + + +class DEFireFlags(base.Record): + """SISO-REF-010-2025 18.5.2 DE Fire Flags [UID 313]""" + + _struct = bitfield.bitfield(name="DEFireFlags", fields=[ + ("weaponOn", bitfield.INTEGER, 1), # state of the DE Weapon + ("stateUpdateFlag", bitfield.INTEGER, 1), # DE Weapon State Change + ("padding", bitfield.INTEGER, 14) # unused bits + ]) + + def __init__(self, + weaponOn: bool = False, + stateUpdateFlag: bf_enum = 0): # [UID 299] + # Net number ranging from 0 to 999 decimal + self.weaponOn = weaponOn + self.stateUpdateFlag = stateUpdateFlag + self.padding: bf_uint = 0 + + def marshalledSize(self) -> int: + return self._struct.marshalledSize() + + def serialize(self, outputStream: DataOutputStream) -> None: + self._struct( + self.weaponOn, + self.stateUpdateFlag, + ).serialize(outputStream) + + def parse(self, inputStream: DataInputStream) -> None: + record_bitfield = self._struct.parse(inputStream) + self.weaponOn = record_bitfield.weaponOn + self.stateUpdateFlag = record_bitfield.stateUpdateFlag + self.padding = record_bitfield.padding From 50b49e6bb62fb4bb30990143417ef017d7a8afa3 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 08:56:38 +0000 Subject: [PATCH 31/37] feat: handle StandardVariable damage description records in EntityDamageStatusPdu --- opendis/dis7.py | 50 +++++++++++++++++--------------- opendis/record/radio/__init__.py | 3 ++ 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 2f7d21b..e24c759 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -6620,47 +6620,49 @@ class EntityDamageStatusPdu(WarfareFamilyPdu): """Section 7.3.5 Shall be used to communicate detailed damage information sustained by an - entity regardless of the source of the damage. COMPLETE + entity regardless of the source of the damage. """ pduType: enum8 = 69 # [UID 4] def __init__(self, - damagedEntityID: "EntityIdentifier | None" = None, - damageDescriptionRecords=None): + damagedEntityID: EntityIdentifier | None = None, + damageDescriptions: list[DamageDescriptionRecord] | None = None): super(EntityDamageStatusPdu, self).__init__() self.damagedEntityID = damagedEntityID or EntityIdentifier() - """Field shall identify the damaged entity (see 6.2.28), Section 7.3.4 COMPLETE""" self.padding1: uint16 = 0 self.padding2: uint16 = 0 # TODO: Look into using StandardVariableSpecification to compose this - self.damageDescriptionRecords = damageDescriptionRecords or [] - """Fields shall contain one or more Damage Description records (see 6.2.17) and may contain other Standard Variable records, Section 7.3.5""" + self.damageDescriptions: list[DamageDescriptionRecord] = damageDescriptions or [] @property - def numberOfDamageDescriptions(self) -> uint16: - return len(self.damageDescriptionRecords) + def damageDescriptionCount(self) -> uint16: + return len(self.damageDescriptions) - def serialize(self, outputStream): - """serialize the class""" + def serialize(self, outputStream: DataOutputStream) -> None: super(EntityDamageStatusPdu, self).serialize(outputStream) self.damagedEntityID.serialize(outputStream) - outputStream.write_unsigned_short(self.padding1) - outputStream.write_unsigned_short(self.padding2) - outputStream.write_unsigned_short(self.numberOfDamageDescriptions) - for anObj in self.damageDescriptionRecords: - anObj.serialize(outputStream) + outputStream.write_uint16(self.padding1) + outputStream.write_uint16(self.padding2) + outputStream.write_uint16(self.damageDescriptionCount) + for record in self.damageDescriptions: + record.serialize(outputStream) - def parse(self, inputStream): - """Parse a message. This may recursively call embedded objects.""" + def parse(self, inputStream: DataInputStream) -> None: super(EntityDamageStatusPdu, self).parse(inputStream) self.damagedEntityID.parse(inputStream) - self.padding1 = inputStream.read_unsigned_short() - self.padding2 = inputStream.read_unsigned_short() - numberOfDamageDescriptions = inputStream.read_unsigned_short() - for idx in range(0, numberOfDamageDescriptions): - element = null() - element.parse(inputStream) - self.damageDescriptionRecords.append(element) + self.padding1 = inputStream.read_uint16() + self.padding2 = inputStream.read_uint16() + damageDescriptionCount = inputStream.read_uint16() + for _ in range(0, damageDescriptionCount): + recordType = inputStream.read_uint32() + recordLength = inputStream.read_uint16() + svClass = getSVClass( + recordType, + expectedType=DamageDescriptionRecord + ) + record = svClass() + record.parse(inputStream, recordLength) + self.damageDescriptions.append(record) class FirePdu(WarfareFamilyPdu): diff --git a/opendis/record/radio/__init__.py b/opendis/record/radio/__init__.py index 1ba6516..8667ef5 100644 --- a/opendis/record/radio/__init__.py +++ b/opendis/record/radio/__init__.py @@ -1,7 +1,9 @@ """Radio Family PDU record types""" __all__ = [ + "AntennaPatternRecord", "BasicHaveQuickMP", + "BeamAntennaPattern", "CCTTSincgarsMP", "GenericRadio", "HighFidelityHAVEQUICKRadio", @@ -10,6 +12,7 @@ "NetId", "SimpleIntercomRadio", "SpreadSpectrum", + "UnknownAntennaPattern", "UnknownRadio", "VariableTransmitterParametersRecord", ] From a0c66b65ddde317d84e0b16b6c04964721855d31 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 08:59:42 +0000 Subject: [PATCH 32/37] refactor: migrate DataInputStream and DataOutputStream to stream --- opendis/DataInputStream.py | 103 --------------------- opendis/DataOutputStream.py | 103 --------------------- opendis/stream.py | 179 +++++++++++++++++++++++++++++++++++- 3 files changed, 177 insertions(+), 208 deletions(-) delete mode 100644 opendis/DataInputStream.py delete mode 100644 opendis/DataOutputStream.py diff --git a/opendis/DataInputStream.py b/opendis/DataInputStream.py deleted file mode 100644 index 4d95edc..0000000 --- a/opendis/DataInputStream.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Reading from Java DataInputStream format. -From https://github.com/arngarden/python_java_datastream -This uses big endian (network) format. -""" - -from io import BufferedIOBase -import struct - -from .types import ( - int8, - int16, - int32, - int64, - uint8, - uint16, - uint32, - uint64, - float32, - float64, - char8, - char16, -) - - -class DataInputStream: - def __init__(self, stream: BufferedIOBase): - self.stream = stream - - def read_boolean(self) -> bool: - return struct.unpack('?', self.stream.read(1))[0] - - def read_byte(self) -> int8: - return struct.unpack('b', self.stream.read(1))[0] - - def read_unsigned_byte(self) -> uint8: - return struct.unpack('B', self.stream.read(1))[0] - - def read_char(self) -> char16: - return chr(struct.unpack('>H', self.stream.read(2))[0]) - - def read_double(self) -> float64: - return struct.unpack('>d', self.stream.read(8))[0] - - def read_float(self) -> float32: - return struct.unpack('>f', self.stream.read(4))[0] - - def read_short(self) -> int16: - return struct.unpack('>h', self.stream.read(2))[0] - - def read_unsigned_short(self) -> uint16: - return struct.unpack('>H', self.stream.read(2))[0] - - def read_long(self) -> int64: - return struct.unpack('>q', self.stream.read(8))[0] - - def read_utf(self) -> bytes: - utf_length = struct.unpack('>H', self.stream.read(2))[0] - return self.stream.read(utf_length) - - def read_int(self) -> int32: - return struct.unpack('>i', self.stream.read(4))[0] - - def read_unsigned_int(self) -> uint32: - return struct.unpack('>I', self.stream.read(4))[0] - - def read_bytes(self, n: int) -> bytes: - """Read n bytes from the stream.""" - return self.stream.read(n) - - # Aliases for convenience - def read_char8(self) -> char8: - return char8(self.read_char()) - - def read_float32(self) -> float32: - return float32(self.read_float()) - - def read_float64(self) -> float64: - return float64(self.read_double()) - - def read_int8(self) -> int8: - return int8(self.read_byte()) - - def read_int16(self) -> int16: - return int16(self.read_short()) - - def read_int32(self) -> int32: - return int32(self.read_int()) - - def read_int64(self) -> int64: - return int64(self.read_long()) - - def read_uint8(self) -> uint8: - return uint8(self.read_unsigned_byte()) - - def read_uint16(self) -> uint16: - return uint16(self.read_unsigned_short()) - - def read_uint32(self) -> uint32: - return uint32(self.read_unsigned_int()) - - def read_uint64(self) -> uint64: - return uint64(self.read_long()) diff --git a/opendis/DataOutputStream.py b/opendis/DataOutputStream.py deleted file mode 100644 index e3cb3db..0000000 --- a/opendis/DataOutputStream.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Writing to Java DataInputStream format. -From https://github.com/arngarden/python_java_datastream/blob/master/data_output_stream.py -This uses big endian (network) format -""" - -from io import BufferedIOBase -import struct - -from .types import ( - int8, - int16, - int32, - int64, - uint8, - uint16, - uint32, - uint64, - float32, - float64, - char8, - char16, -) - - -class DataOutputStream: - def __init__(self, stream: BufferedIOBase): - self.stream = stream - - def write_boolean(self, boolean: bool) -> None: - self.stream.write(struct.pack('?', boolean)) - - def write_byte(self, val: int) -> None: - self.stream.write(struct.pack('b', val)) - - def write_unsigned_byte(self, val: int) -> None: - self.stream.write(struct.pack('B', val)) - - def write_char(self, val: str) -> None: - self.stream.write(struct.pack('>H', ord(val))) - - def write_double(self, val: float) -> None: - self.stream.write(struct.pack('>d', val)) - - def write_float(self, val: float) -> None: - self.stream.write(struct.pack('>f', val)) - - def write_short(self, val: int) -> None: - self.stream.write(struct.pack('>h', val)) - - def write_unsigned_short(self, val: int) -> None: - self.stream.write(struct.pack('>H', val)) - - def write_long(self, val: int) -> None: - self.stream.write(struct.pack('>q', val)) - - def write_utf(self, string: bytes) -> None: - self.stream.write(struct.pack('>H', len(string))) - self.stream.write(string) - - def write_int(self, val: int) -> None: - self.stream.write(struct.pack('>i', val)) - - def write_unsigned_int(self, val: int) -> None: - self.stream.write(struct.pack('>I', val)) - - def write_bytes(self, val: bytes) -> None: - """Write bytes to the stream.""" - self.stream.write(val) - - # Aliases for convenience - def write_char8(self, val: char8) -> None: - self.write_byte(ord(val)) - - def write_float32(self, val: float32) -> None: - self.write_float(val) - - def write_float64(self, val: float64) -> None: - self.write_double(val) - - def write_int8(self, val: int8) -> None: - self.write_byte(val) - - def write_int16(self, val: int16) -> None: - self.write_short(val) - - def write_int32(self, val: int32) -> None: - self.write_int(val) - - def write_int64(self, val: int64) -> None: - self.write_long(val) - - def write_uint8(self, val: uint8) -> None: - self.write_unsigned_byte(val) - - def write_uint16(self, val: uint16) -> None: - self.write_unsigned_short(val) - - def write_uint32(self, val: uint32) -> None: - self.write_unsigned_int(val) - - def write_uint64(self, val: uint64) -> None: - self.stream.write(struct.pack('>Q', val)) diff --git a/opendis/stream.py b/opendis/stream.py index 45731d8..908e976 100644 --- a/opendis/stream.py +++ b/opendis/stream.py @@ -5,5 +5,180 @@ __all__ = ['DataInputStream', 'DataOutputStream'] -from .DataInputStream import DataInputStream -from .DataOutputStream import DataOutputStream +from io import BufferedIOBase +import struct + +from .types import ( + int8, + int16, + int32, + int64, + uint8, + uint16, + uint32, + uint64, + float32, + float64, + char8, + char16, +) + + +class DataInputStream: + def __init__(self, stream: BufferedIOBase): + self.stream = stream + + def read_boolean(self) -> bool: + return struct.unpack('?', self.stream.read(1))[0] + + def read_byte(self) -> int8: + return struct.unpack('b', self.stream.read(1))[0] + + def read_unsigned_byte(self) -> uint8: + return struct.unpack('B', self.stream.read(1))[0] + + def read_char(self) -> char16: + return chr(struct.unpack('>H', self.stream.read(2))[0]) + + def read_double(self) -> float64: + return struct.unpack('>d', self.stream.read(8))[0] + + def read_float(self) -> float32: + return struct.unpack('>f', self.stream.read(4))[0] + + def read_short(self) -> int16: + return struct.unpack('>h', self.stream.read(2))[0] + + def read_unsigned_short(self) -> uint16: + return struct.unpack('>H', self.stream.read(2))[0] + + def read_long(self) -> int64: + return struct.unpack('>q', self.stream.read(8))[0] + + def read_utf(self) -> bytes: + utf_length = struct.unpack('>H', self.stream.read(2))[0] + return self.stream.read(utf_length) + + def read_int(self) -> int32: + return struct.unpack('>i', self.stream.read(4))[0] + + def read_unsigned_int(self) -> uint32: + return struct.unpack('>I', self.stream.read(4))[0] + + def read_bytes(self, n: int) -> bytes: + """Read n bytes from the stream.""" + return self.stream.read(n) + + # Aliases for convenience + def read_char8(self) -> char8: + return char8(self.read_char()) + + def read_float32(self) -> float32: + return float32(self.read_float()) + + def read_float64(self) -> float64: + return float64(self.read_double()) + + def read_int8(self) -> int8: + return int8(self.read_byte()) + + def read_int16(self) -> int16: + return int16(self.read_short()) + + def read_int32(self) -> int32: + return int32(self.read_int()) + + def read_int64(self) -> int64: + return int64(self.read_long()) + + def read_uint8(self) -> uint8: + return uint8(self.read_unsigned_byte()) + + def read_uint16(self) -> uint16: + return uint16(self.read_unsigned_short()) + + def read_uint32(self) -> uint32: + return uint32(self.read_unsigned_int()) + + def read_uint64(self) -> uint64: + return uint64(self.read_long()) + + +class DataOutputStream: + def __init__(self, stream: BufferedIOBase): + self.stream = stream + + def write_boolean(self, boolean: bool) -> None: + self.stream.write(struct.pack('?', boolean)) + + def write_byte(self, val: int) -> None: + self.stream.write(struct.pack('b', val)) + + def write_unsigned_byte(self, val: int) -> None: + self.stream.write(struct.pack('B', val)) + + def write_char(self, val: str) -> None: + self.stream.write(struct.pack('>H', ord(val))) + + def write_double(self, val: float) -> None: + self.stream.write(struct.pack('>d', val)) + + def write_float(self, val: float) -> None: + self.stream.write(struct.pack('>f', val)) + + def write_short(self, val: int) -> None: + self.stream.write(struct.pack('>h', val)) + + def write_unsigned_short(self, val: int) -> None: + self.stream.write(struct.pack('>H', val)) + + def write_long(self, val: int) -> None: + self.stream.write(struct.pack('>q', val)) + + def write_utf(self, string: bytes) -> None: + self.stream.write(struct.pack('>H', len(string))) + self.stream.write(string) + + def write_int(self, val: int) -> None: + self.stream.write(struct.pack('>i', val)) + + def write_unsigned_int(self, val: int) -> None: + self.stream.write(struct.pack('>I', val)) + + def write_bytes(self, val: bytes) -> None: + """Write bytes to the stream.""" + self.stream.write(val) + + # Aliases for convenience + def write_char8(self, val: char8) -> None: + self.write_byte(ord(val)) + + def write_float32(self, val: float32) -> None: + self.write_float(val) + + def write_float64(self, val: float64) -> None: + self.write_double(val) + + def write_int8(self, val: int8) -> None: + self.write_byte(val) + + def write_int16(self, val: int16) -> None: + self.write_short(val) + + def write_int32(self, val: int32) -> None: + self.write_int(val) + + def write_int64(self, val: int64) -> None: + self.write_long(val) + + def write_uint8(self, val: uint8) -> None: + self.write_unsigned_byte(val) + + def write_uint16(self, val: uint16) -> None: + self.write_unsigned_short(val) + + def write_uint32(self, val: uint32) -> None: + self.write_unsigned_int(val) + + def write_uint64(self, val: uint64) -> None: + self.stream.write(struct.pack('>Q', val)) From d422bfa15c179ec2cd4461de828999766be7e754 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 09:08:52 +0000 Subject: [PATCH 33/37] refactor: use parseStandardVariableRecord helper to enforce consistent operations --- opendis/dis7.py | 54 +++++++++++++++++++++++--------------- opendis/record/__init__.py | 1 + 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index e24c759..111bcdf 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -24,7 +24,9 @@ CCTTSincgarsMP, VariableTransmitterParametersRecord, DamageDescriptionRecord, + StandardVariableRecord, getSVClass, + base ) from .stream import DataInputStream, DataOutputStream from .types import ( @@ -44,6 +46,28 @@ ) +def parseStandardVariableRecord( + inputStream: DataInputStream, + expectedType: type[base.StandardVariableRecord], +) -> base.StandardVariableRecord: + """Parse a single Standard Variable Record from the input stream. + These rely on recordType enums from [UID 66]. + The mapping of recordType to class is defined in opendis.radio + + Args: + inputStream: The DataInputStream to read from. + + Returns: + An instance of a StandardVariableRecord subclass. + """ + recordType = inputStream.read_uint32() + recordLength = inputStream.read_uint16() + svClass = getSVClass(recordType, expectedType) + sv_instance = svClass() + sv_instance.parse(inputStream, recordLength) + return sv_instance + + class DataQueryDatumSpecification: """Section 6.2.17 @@ -5085,14 +5109,10 @@ def parse(self, inputStream: DataInputStream) -> None: self.antennaPattern = None for _ in range(0, variableTransmitterParameterCount): - recordType = inputStream.read_uint32() - recordLength = inputStream.read_uint16() - vtpClass = getSVClass( - recordType, - expectedType=VariableTransmitterParametersRecord + vtp = parseStandardVariableRecord( + inputStream, + VariableTransmitterParametersRecord ) - vtp = vtpClass() - vtp.parse(inputStream, bytelength=recordLength) self.variableTransmitterParameters.append(vtp) @@ -6376,14 +6396,10 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding4 = inputStream.read_uint16() numberOfDERecords = inputStream.read_uint16() for _ in range(0, numberOfDERecords): - recordType = inputStream.read_uint32() - recordLength = inputStream.read_uint16() - vtpClass = getSVClass( - recordType, - expectedType=DamageDescriptionRecord + vtp = parseStandardVariableRecord( + inputStream, + DamageDescriptionRecord ) - vtp = vtpClass() # pyright: ignore[reportAbstractUsage] - vtp.parse(inputStream, bytelength=recordLength) self.dERecords.append(vtp) @@ -6654,14 +6670,10 @@ def parse(self, inputStream: DataInputStream) -> None: self.padding2 = inputStream.read_uint16() damageDescriptionCount = inputStream.read_uint16() for _ in range(0, damageDescriptionCount): - recordType = inputStream.read_uint32() - recordLength = inputStream.read_uint16() - svClass = getSVClass( - recordType, - expectedType=DamageDescriptionRecord + record = parseStandardVariableRecord( + inputStream, + DamageDescriptionRecord ) - record = svClass() - record.parse(inputStream, recordLength) self.damageDescriptions.append(record) diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index 8b529f8..c47e18c 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -22,6 +22,7 @@ ) from . import base, bitfield, symbolic_names as sym +from .base import StandardVariableRecord from .common import * from .radio import * from .warfare import * From 828f15e237a00b699ac99b56183d9e05fb5efd3b Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 09:10:46 +0000 Subject: [PATCH 34/37] fix: typos --- opendis/record/base.py | 1 - tests/test_SignalPdu.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/opendis/record/base.py b/opendis/record/base.py index 416321f..735025f 100644 --- a/opendis/record/base.py +++ b/opendis/record/base.py @@ -58,7 +58,6 @@ def parse(self, # TODO: Implement padding handling -@runtime_checkable class StandardVariableRecord(VariableRecord): """6.2.83 Standard Variable (SV) Record diff --git a/tests/test_SignalPdu.py b/tests/test_SignalPdu.py index e39c2e5..300a2e7 100644 --- a/tests/test_SignalPdu.py +++ b/tests/test_SignalPdu.py @@ -6,7 +6,7 @@ from opendis.dis7 import * from opendis.PduFactory import * -from opendis.DataOutputStream import DataOutputStream +from opendis.stream import DataOutputStream class TestSignalPdu(unittest.TestCase): From 78385a8d2aae02633d0c587376ec2ddd52d42abb Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 09:17:14 +0000 Subject: [PATCH 35/37] format: rename for consistency --- opendis/dis7.py | 4 ++-- opendis/record/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/opendis/dis7.py b/opendis/dis7.py index 111bcdf..1b0036b 100644 --- a/opendis/dis7.py +++ b/opendis/dis7.py @@ -25,7 +25,7 @@ VariableTransmitterParametersRecord, DamageDescriptionRecord, StandardVariableRecord, - getSVClass, + getStandardVariableClass, base ) from .stream import DataInputStream, DataOutputStream @@ -62,7 +62,7 @@ def parseStandardVariableRecord( """ recordType = inputStream.read_uint32() recordLength = inputStream.read_uint16() - svClass = getSVClass(recordType, expectedType) + svClass = getStandardVariableClass(recordType, expectedType) sv_instance = svClass() sv_instance.parse(inputStream, recordLength) return sv_instance diff --git a/opendis/record/__init__.py b/opendis/record/__init__.py index c47e18c..50f57b7 100644 --- a/opendis/record/__init__.py +++ b/opendis/record/__init__.py @@ -37,7 +37,7 @@ 4500: DirectedEnergyDamage, } -def getSVClass( +def getStandardVariableClass( recordType: int, expectedType: type[SV] = base.StandardVariableRecord ) -> type[SV]: From efdb502f7f39e7a86696d9eb6a2f8d12f980394d Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 09:25:12 +0000 Subject: [PATCH 36/37] fix: typos --- examples/dis_sender.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/dis_sender.py b/examples/dis_sender.py index c7a0734..c600785 100644 --- a/examples/dis_sender.py +++ b/examples/dis_sender.py @@ -8,7 +8,7 @@ from io import BytesIO -from opendis.DataOutputStream import DataOutputStream +from opendis.stream import DataOutputStream from opendis.dis7 import EntityStatePdu from opendis.RangeCoordinates import * @@ -23,9 +23,9 @@ def send(): pdu = EntityStatePdu() - pdu.entityID.entityID = 42 - pdu.entityID.siteID = 17 - pdu.entityID.applicationID = 23 + pdu.entityID.entityNumber = 42 + pdu.entityID.simulationAddress.site = 17 + pdu.entityID.simulationAddress.application = 23 pdu.marking.setString('Igor3d') # Entity in Monterey, CA, USA facing North, no roll or pitch From 2ae417b7862080b8cb330ad0f7fcc5b4be7eafc5 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Tue, 7 Oct 2025 09:33:40 +0000 Subject: [PATCH 37/37] fix: typos, type checking --- examples/dis_receiver.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/dis_receiver.py b/examples/dis_receiver.py index 9f17705..25b118f 100644 --- a/examples/dis_receiver.py +++ b/examples/dis_receiver.py @@ -5,8 +5,7 @@ import socket import time -import sys -import array +from typing import cast from opendis.dis7 import * from opendis.RangeCoordinates import * @@ -20,7 +19,7 @@ print("Listening for DIS on UDP socket {}".format(UDP_PORT)) -gps = GPS(); +gps = GPS() def recv(): print('Reading from socket...') @@ -31,6 +30,7 @@ def recv(): pduTypeName = pdu.__class__.__name__ if pdu.pduType == 1: # PduTypeDecoders.EntityStatePdu: + pdu = cast(EntityStatePdu, pdu) # for static checkers loc = (pdu.entityLocation.x, pdu.entityLocation.y, pdu.entityLocation.z, @@ -42,7 +42,7 @@ def recv(): body = gps.ecef2llarpy(*loc) print("Received {}\n".format(pduTypeName) - + " Id : {}\n".format(pdu.entityID.entityID) + + " Id : {}\n".format(pdu.entityID.entityNumber) + " Latitude : {:.2f} degrees\n".format(rad2deg(body[0])) + " Longitude : {:.2f} degrees\n".format(rad2deg(body[1])) + " Altitude : {:.0f} meters\n".format(body[2])