From d643ebe6c1839443ca76277e7464357bceeeb558 Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sat, 22 Jan 2022 15:36:22 +0100 Subject: [PATCH 01/18] Adds a very basic PoC on iterative message decoding --- pyais/encode.py | 37 ++++++++++++++++++++++++++++++++++++- tests/test_decode.py | 11 +++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/test_decode.py diff --git a/pyais/encode.py b/pyais/encode.py index ffbfb3a..a71a66f 100644 --- a/pyais/encode.py +++ b/pyais/encode.py @@ -5,7 +5,8 @@ import attr import bitarray -from pyais.util import chunks, from_bytes, compute_checksum +from pyais import NMEAMessage +from pyais.util import chunks, from_bytes, compute_checksum, get_int # Types DATA_DICT = typing.Dict[str, typing.Union[str, int, float, bytes, bool]] @@ -240,6 +241,40 @@ class MessageType1(Payload): raim = bit_field(1, int, default=0) radio = bit_field(19, int, default=0) + @classmethod + def from_bytes(cls, byte_str: bytes) -> "MessageType1": + # Split the message & get the payload as a bit sequence + msg = NMEAMessage.from_bytes(byte_str) + bit_arr = msg.bit_array + + cur = 0 + + kwargs = {} + + # Iterate over the bits until the last bit of the bitarray or all fields are fully decoded + for field in cls.fields(): + width = field.metadata['width'] + d_type = field.metadata['d_type'] + + end = min(len(bit_arr), cur + width) + bits = bit_arr[cur: cur + width] + + # Get the correct data type and decoding function + if d_type == int or d_type == bool: + shift = 8 - (width % 8) + val = from_bytes(bits) >> shift + else: + raise ValueError() + + kwargs[field.name] = val + + if end >= len(bit_arr): + break + + cur = end + + return cls(**kwargs) + class MessageType2(MessageType1): """ diff --git a/tests/test_decode.py b/tests/test_decode.py new file mode 100644 index 0000000..8f0d9d3 --- /dev/null +++ b/tests/test_decode.py @@ -0,0 +1,11 @@ +from pyais.encode import MessageType1 + + +def test_decode_type_1(): + msg = MessageType1.from_bytes(b"!AIVDM,1,1,,A,15NPOOPP00o?b=bE`UNv4?w428D;,0*24") + + assert msg.msg_type == 1 + assert msg.repeat == 0 + assert msg.mmsi == 367533950 + assert msg.turn == 0 + assert msg.speed == 0 \ No newline at end of file From 3297214db9b0e7e88ec4ad72456b93ad953c2b01 Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sun, 23 Jan 2022 12:41:13 +0100 Subject: [PATCH 02/18] Add from_conversion and to_conversion --- pyais/encode.py | 182 +++++++++++++++++++++++++++++------------------- 1 file changed, 112 insertions(+), 70 deletions(-) diff --git a/pyais/encode.py b/pyais/encode.py index a71a66f..3d25c9e 100644 --- a/pyais/encode.py +++ b/pyais/encode.py @@ -6,7 +6,7 @@ import bitarray from pyais import NMEAMessage -from pyais.util import chunks, from_bytes, compute_checksum, get_int +from pyais.util import chunks, from_bytes, compute_checksum # Types DATA_DICT = typing.Dict[str, typing.Union[str, int, float, bytes, bool]] @@ -133,18 +133,28 @@ def str_to_bin(val: str, width: int) -> bitarray.bitarray: def bit_field(width: int, d_type: typing.Type[typing.Any], - converter: typing.Optional[typing.Callable[[typing.Any], typing.Any]] = None, + from_converter: typing.Optional[typing.Callable[[typing.Any], typing.Any]] = None, + to_converter: typing.Optional[typing.Callable[[typing.Any], typing.Any]] = None, default: typing.Optional[typing.Any] = None) -> typing.Any: """ Simple wrapper around the attr.ib interface to be used in conjunction with the Payload class. - @param width: The bit-width of the field. - @param d_type: The datatype of the fields value. - @param converter: Optional converter function to convert values before storing them. - @param default: Optional default value to be used when no value is explicitly passed. - @return: An attr.ib field instance. + @param width: The bit-width of the field. + @param d_type: The datatype of the fields value. + @param from_converter: Optional converter function called **before** encoding + @param to_converter: Optional converter function called **after** decoding + @param default: Optional default value to be used when no value is explicitly passed. + @return: An attr.ib field instance. """ - return attr.ib(converter=converter, metadata={'width': width, 'd_type': d_type, 'default': default}) + return attr.ib( + metadata={ + 'width': width, + 'd_type': d_type, + 'from_converter': from_converter, + 'to_converter': to_converter, + 'default': default, + }, + ) @attr.s(slots=True) @@ -171,8 +181,10 @@ def to_bitarray(self) -> bitarray.bitarray: for field in self.fields(): width = field.metadata['width'] d_type = field.metadata['d_type'] + converter = field.metadata['from_converter'] val = getattr(self, field.name) + val = converter(val) if converter is not None else val val = d_type(val) if d_type == int or d_type == bool: @@ -217,30 +229,6 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payloa args[key] = default return cls(**args) - -@attr.s(slots=True) -class MessageType1(Payload): - """ - AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a - """ - msg_type = bit_field(6, int, default=1) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - status = bit_field(4, int, default=0) - turn = bit_field(8, int, default=0) - speed = bit_field(10, int, converter=lambda v: float(v) * 10.0, default=0) - accuracy = bit_field(1, int, default=0) - lon = bit_field(28, int, converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, converter=lambda v: float(v) * 600000.0, default=0) - course = bit_field(12, int, converter=lambda v: float(v) * 10.0, default=0) - heading = bit_field(9, int, default=0) - second = bit_field(6, int, default=0) - maneuver = bit_field(2, int, default=0) - spare = bit_field(3, int, default=0) - raim = bit_field(1, int, default=0) - radio = bit_field(19, int, default=0) - @classmethod def from_bytes(cls, byte_str: bytes) -> "MessageType1": # Split the message & get the payload as a bit sequence @@ -255,6 +243,7 @@ def from_bytes(cls, byte_str: bytes) -> "MessageType1": for field in cls.fields(): width = field.metadata['width'] d_type = field.metadata['d_type'] + converter = field.metadata['to_converter'] end = min(len(bit_arr), cur + width) bits = bit_arr[cur: cur + width] @@ -266,6 +255,7 @@ def from_bytes(cls, byte_str: bytes) -> "MessageType1": else: raise ValueError() + val = converter(val) if converter is not None else val kwargs[field.name] = val if end >= len(bit_arr): @@ -276,6 +266,58 @@ def from_bytes(cls, byte_str: bytes) -> "MessageType1": return cls(**kwargs) +# +# Conversion functions +# + +def from_speed(v: int) -> float: + return float(v) * 10.0 + + +def to_speed(v: int) -> float: + return v / 10.0 + + +def from_lat_lon(v: int) -> float: + return float(v) * 600000.0 + + +def to_lat_lon(v: int) -> float: + return float(v) / 600000.0 + + +def from_course(v: int) -> float: + return float(v) * 10.0 + + +def to_course(v: int) -> float: + return v / 10.0 + + +@attr.s(slots=True) +class MessageType1(Payload): + """ + AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a + """ + msg_type = bit_field(6, int, default=1) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int) + status = bit_field(4, int, default=0) + turn = bit_field(8, int, default=0) + speed = bit_field(10, int, from_converter=from_speed, to_converter=to_speed, default=0) + accuracy = bit_field(1, int, default=0) + lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, default=0) + lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, default=0) + course = bit_field(12, int, from_converter=from_course, to_converter=to_course, default=0) + heading = bit_field(9, int, default=0) + second = bit_field(6, int, default=0) + maneuver = bit_field(2, int, default=0) + spare = bit_field(3, int, default=0) + raim = bit_field(1, int, default=0) + radio = bit_field(19, int, default=0) + + class MessageType2(MessageType1): """ AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) @@ -308,8 +350,8 @@ class MessageType4(Payload): minute = bit_field(6, int, default=0) second = bit_field(6, int, default=0) accuracy = bit_field(1, int, default=0) - lon = bit_field(28, int, converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, converter=lambda v: float(v) * 600000.0, default=0) + lon = bit_field(28, int, from_converter=lambda v: float(v) * 600000.0, default=0) + lat = bit_field(27, int, from_converter=lambda v: float(v) * 600000.0, default=0) epfd = bit_field(4, int, default=0) spare = bit_field(10, int, default=0) raim = bit_field(1, int, default=0) @@ -339,7 +381,7 @@ class MessageType5(Payload): day = bit_field(5, int, default=0) hour = bit_field(5, int, default=0) minute = bit_field(6, int, default=0) - draught = bit_field(8, int, converter=lambda v: float(v) * 10.0, default=0) + draught = bit_field(8, int, from_converter=lambda v: float(v) * 10.0, default=0) destination = bit_field(120, str, default='') dte = bit_field(1, int, default=0) spare = bit_field(1, int, default=0) @@ -360,7 +402,7 @@ class MessageType6(Payload): spare = bit_field(1, int, default=0) dac = bit_field(10, int, default=0) fid = bit_field(6, int, default=0) - data = bit_field(920, int, default=0, converter=int_to_bytes) + data = bit_field(920, int, default=0, from_converter=int_to_bytes) @attr.s(slots=True) @@ -395,7 +437,7 @@ class MessageType8(Payload): spare = bit_field(2, int, default=0) dac = bit_field(10, int, default=0) fid = bit_field(6, int, default=0) - data = bit_field(952, int, default=0, converter=int_to_bytes) + data = bit_field(952, int, default=0, from_converter=int_to_bytes) @attr.s(slots=True) @@ -410,9 +452,9 @@ class MessageType9(Payload): alt = bit_field(12, int, default=0) speed = bit_field(10, int, default=0) accuracy = bit_field(1, int, default=0) - lon = bit_field(28, int, converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, converter=lambda v: float(v) * 600000.0, default=0) - course = bit_field(12, int, converter=lambda v: float(v) * 10.0, default=0) + lon = bit_field(28, int, from_converter=lambda v: float(v) * 600000.0, default=0) + lat = bit_field(27, int, from_converter=lambda v: float(v) * 600000.0, default=0) + course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) second = bit_field(6, int, default=0) reserved = bit_field(8, int, default=0) dte = bit_field(1, int, default=0) @@ -533,10 +575,10 @@ class MessageType17(Payload): repeat = bit_field(2, int, default=0) mmsi = bit_field(30, int) spare_1 = bit_field(2, int, default=0) - lon = bit_field(18, int, converter=lambda v: float(v) * 10.0, default=0) - lat = bit_field(17, int, converter=lambda v: float(v) * 10.0, default=0) + lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) + lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) spare_2 = bit_field(5, int, default=0) - data = bit_field(736, int, default=0, converter=int_to_bytes) + data = bit_field(736, int, default=0, from_converter=int_to_bytes) @attr.s(slots=True) @@ -551,9 +593,9 @@ class MessageType18(Payload): reserved = bit_field(8, int, default=0) speed = bit_field(10, int, default=0) accuracy = bit_field(1, int, default=0) - lon = bit_field(28, int, converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, converter=lambda v: float(v) * 600000.0, default=0) - course = bit_field(12, int, converter=lambda v: float(v) * 10.0, default=0) + lon = bit_field(28, int, from_converter=lambda v: float(v) * 600000.0, default=0) + lat = bit_field(27, int, from_converter=lambda v: float(v) * 600000.0, default=0) + course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) heading = bit_field(9, int, default=0) second = bit_field(6, int, default=0) reserved_2 = bit_field(2, int, default=0) @@ -577,11 +619,11 @@ class MessageType19(Payload): repeat = bit_field(2, int, default=0) mmsi = bit_field(30, int) reserved = bit_field(8, int, default=0) - speed = bit_field(10, int, converter=lambda v: float(v) * 10.0, default=0) + speed = bit_field(10, int, from_converter=lambda v: float(v) * 10.0, default=0) accuracy = bit_field(1, int, default=0) - lon = bit_field(28, int, converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, converter=lambda v: float(v) * 600000.0, default=0) - course = bit_field(12, int, converter=lambda v: float(v) * 10.0, default=0) + lon = bit_field(28, int, from_converter=lambda v: float(v) * 600000.0, default=0) + lat = bit_field(27, int, from_converter=lambda v: float(v) * 600000.0, default=0) + course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) heading = bit_field(9, int, default=0) second = bit_field(6, int, default=0) regional = bit_field(4, int, default=0) @@ -643,8 +685,8 @@ class MessageType21(Payload): aid_type = bit_field(5, int, default=0) shipname = bit_field(120, str, default='') accuracy = bit_field(1, bool, default=0) - lon = bit_field(28, int, converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, converter=lambda v: float(v) * 600000.0, default=0) + lon = bit_field(28, int, from_converter=lambda v: float(v) * 600000.0, default=0) + lat = bit_field(27, int, from_converter=lambda v: float(v) * 600000.0, default=0) to_bow = bit_field(9, int, default=0) to_stern = bit_field(9, int, default=0) to_port = bit_field(6, int, default=0) @@ -710,10 +752,10 @@ class MessageType22Broadcast(Payload): # If the message is broadcast (addressed field is 0), # the ne_lon, ne_lat, sw_lon, and sw_lat fields are the # corners of a rectangular jurisdiction area over which control parameter - ne_lon = bit_field(18, int, converter=lambda v: float(v) * 10.0, default=0) - ne_lat = bit_field(17, int, converter=lambda v: float(v) * 10.0, default=0) - sw_lon = bit_field(18, int, converter=lambda v: float(v) * 10.0, default=0) - sw_lat = bit_field(17, int, converter=lambda v: float(v) * 10.0, default=0) + ne_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) + ne_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) + sw_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) + sw_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) addressed = bit_field(1, bool, default=0) band_a = bit_field(1, bool, default=0) @@ -752,10 +794,10 @@ class MessageType23(Payload): mmsi = bit_field(30, int) spare_1 = bit_field(2, int, default=0) - ne_lon = bit_field(18, int, converter=lambda v: float(v) * 10.0, default=0) - ne_lat = bit_field(17, int, converter=lambda v: float(v) * 10.0, default=0) - sw_lon = bit_field(18, int, converter=lambda v: float(v) * 10.0, default=0) - sw_lat = bit_field(17, int, converter=lambda v: float(v) * 10.0, default=0) + ne_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) + ne_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) + sw_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) + sw_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) station_type = bit_field(4, int, default=0) ship_type = bit_field(8, int, default=0) @@ -832,7 +874,7 @@ class MessageType25AddressedStructured(Payload): dest_mmsi = bit_field(30, int, default=0) app_id = bit_field(16, int, default=0) - data = bit_field(82, int, default=0, converter=int_to_bytes) + data = bit_field(82, int, default=0, from_converter=int_to_bytes) @attr.s(slots=True) @@ -845,7 +887,7 @@ class MessageType25BroadcastStructured(Payload): structured = bit_field(1, bool, default=0) app_id = bit_field(16, int, default=0) - data = bit_field(112, int, default=0, converter=int_to_bytes) + data = bit_field(112, int, default=0, from_converter=int_to_bytes) @attr.s(slots=True) @@ -858,7 +900,7 @@ class MessageType25AddressedUnstructured(Payload): structured = bit_field(1, bool, default=0) dest_mmsi = bit_field(30, int, default=0) - data = bit_field(98, int, default=0, converter=int_to_bytes) + data = bit_field(98, int, default=0, from_converter=int_to_bytes) @attr.s(slots=True) @@ -870,7 +912,7 @@ class MessageType25BroadcastUnstructured(Payload): addressed = bit_field(1, bool, default=0) structured = bit_field(1, bool, default=0) - data = bit_field(128, int, default=0, converter=int_to_bytes) + data = bit_field(128, int, default=0, from_converter=int_to_bytes) class MessageType25(Payload): @@ -911,7 +953,7 @@ class MessageType26AddressedStructured(Payload): dest_mmsi = bit_field(30, int, default=0) app_id = bit_field(16, int, default=0) - data = bit_field(958, int, default=0, converter=int_to_bytes) + data = bit_field(958, int, default=0, from_converter=int_to_bytes) radio = bit_field(20, int, default=0) @@ -925,7 +967,7 @@ class MessageType26BroadcastStructured(Payload): structured = bit_field(1, bool, default=0) app_id = bit_field(16, int, default=0) - data = bit_field(988, int, default=0, converter=int_to_bytes) + data = bit_field(988, int, default=0, from_converter=int_to_bytes) radio = bit_field(20, int, default=0) @@ -940,7 +982,7 @@ class MessageType26AddressedUnstructured(Payload): dest_mmsi = bit_field(30, int, default=0) app_id = bit_field(16, int, default=0) - data = bit_field(958, int, default=0, converter=int_to_bytes) + data = bit_field(958, int, default=0, from_converter=int_to_bytes) radio = bit_field(20, int, default=0) @@ -953,7 +995,7 @@ class MessageType26BroadcastUnstructured(Payload): addressed = bit_field(1, bool, default=0) structured = bit_field(1, bool, default=0) - data = bit_field(1004, int, default=0, converter=int_to_bytes) + data = bit_field(1004, int, default=0, from_converter=int_to_bytes) radio = bit_field(20, int, default=0) @@ -997,8 +1039,8 @@ class MessageType27(Payload): accuracy = bit_field(1, int, default=0) raim = bit_field(1, int, default=0) status = bit_field(4, int, default=0) - lon = bit_field(18, int, converter=lambda v: float(v) * 600.0, default=0) - lat = bit_field(17, int, converter=lambda v: float(v) * 600.0, default=0) + lon = bit_field(18, int, from_converter=lambda v: float(v) * 600.0, default=0) + lat = bit_field(17, int, from_converter=lambda v: float(v) * 600.0, default=0) speed = bit_field(6, int, default=0) course = bit_field(9, int, default=0) gnss = bit_field(1, int, default=0) From b92651af8c430db8a781b1d8e64f6a79e560765f Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sun, 23 Jan 2022 15:29:05 +0100 Subject: [PATCH 03/18] Add basic class based encoding for the first few messages --- pyais/__init__.py | 5 +- pyais/decode.py | 783 +++---------------------------- pyais/encode.py | 1046 +----------------------------------------- pyais/messages.py | 973 +++++++++++++++++++++++++++++++++++++-- pyais/util.py | 147 +++++- tests/test_ais.py | 157 +++---- tests/test_decode.py | 11 - tests/test_encode.py | 27 +- 8 files changed, 1212 insertions(+), 1937 deletions(-) delete mode 100644 tests/test_decode.py diff --git a/pyais/__init__.py b/pyais/__init__.py index 679b49c..8964db9 100644 --- a/pyais/__init__.py +++ b/pyais/__init__.py @@ -1,17 +1,14 @@ -from pyais.messages import NMEAMessage, AISMessage +from pyais.messages import NMEAMessage from pyais.stream import TCPStream, FileReaderStream, IterMessages -from pyais.decode import decode_msg from pyais.encode import encode_dict, encode_payload __license__ = 'MIT' __version__ = '1.7.0' __all__ = ( - 'decode_msg', 'encode_dict', 'encode_payload', 'NMEAMessage', - 'AISMessage', 'TCPStream', 'IterMessages', 'FileReaderStream' diff --git a/pyais/decode.py b/pyais/decode.py index 714defe..c056c44 100644 --- a/pyais/decode.py +++ b/pyais/decode.py @@ -1,733 +1,57 @@ -from functools import partial -from typing import Any, Dict, Union, List - -import bitarray - -from pyais import messages -from pyais.constants import ( - NavigationStatus, - ManeuverIndicator, - TransmitMode, - EpfdType, - ShipType, - StationType, - StationIntervals, - NavAid -) -from pyais.exceptions import UnknownMessageException, MissingMultipartMessageException, TooManyMessagesException -from pyais.util import get_int, decode_bin_as_ascii6, get_mmsi - - -def decode_msg_1(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'status': NavigationStatus(get_int_from_data(38, 42)), - 'turn': get_int_from_data(42, 50, signed=True), - 'speed': get_int_from_data(50, 60) / 10.0, - 'accuracy': bit_arr[60], - 'lon': get_int_from_data(61, 89, signed=True) / 600000.0, - 'lat': get_int_from_data(89, 116, signed=True) / 600000.0, - 'course': get_int_from_data(116, 128) * 0.1, - 'heading': get_int_from_data(128, 137), - 'second': get_int_from_data(137, 143), - 'maneuver': ManeuverIndicator(get_int_from_data(143, 145)), - 'raim': bit_arr[148], - 'radio': get_int_from_data(149, len(bit_arr)), - } - - -def decode_msg_2(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a - """ - return decode_msg_1(bit_arr) - - -def decode_msg_3(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - AIS Vessel position report using ITDMA (Incremental Time Division Multiple Access) - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a - """ - return decode_msg_1(bit_arr) - - -def decode_msg_4(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_4_base_station_report - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'year': get_int_from_data(38, 52), - 'month': get_int_from_data(52, 56), - 'day': get_int_from_data(56, 61), - 'hour': get_int_from_data(61, 66), - 'minute': get_int_from_data(66, 72), - 'second': get_int_from_data(72, 78), - 'accuracy': bit_arr[78], - 'lon': get_int_from_data(79, 107, signed=True) / 600000.0, - 'lat': get_int_from_data(107, 134, signed=True) / 600000.0, - 'epfd': EpfdType(get_int_from_data(134, 138)), - 'raim': bit_arr[148], - 'radio': get_int_from_data(148, len(bit_arr)), - } - - -def decode_msg_5(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Static and Voyage Related Data - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_5_static_and_voyage_related_data - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'ais_version': get_int_from_data(38, 40), - 'imo': get_int_from_data(40, 70), - 'callsign': decode_bin_as_ascii6(bit_arr[70:112]), - 'shipname': decode_bin_as_ascii6(bit_arr[112:232]), - 'shiptype': ShipType(get_int_from_data(232, 240)), - 'to_bow': get_int_from_data(240, 249), - 'to_stern': get_int_from_data(249, 258), - 'to_port': get_int_from_data(258, 264), - 'to_starboard': get_int_from_data(264, 270), - 'epfd': EpfdType(get_int_from_data(270, 274)), - 'month': get_int_from_data(274, 278), - 'day': get_int_from_data(278, 283), - 'hour': get_int_from_data(283, 288), - 'minute': get_int_from_data(288, 294), - 'draught': get_int_from_data(294, 302) / 10.0, - 'destination': decode_bin_as_ascii6(bit_arr[302:422]), - 'dte': bit_arr[-2] - } - - -def decode_msg_6(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Binary Addresses Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_4_base_station_report - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'seqno': get_int_from_data(38, 40), - 'dest_mmsi': get_mmsi(bit_arr, 40, 70), - 'retransmit': bit_arr[70], - 'dac': get_int_from_data(72, 82), - 'fid': get_int_from_data(82, 88), - 'data': bit_arr[88:].to01() - } - - -def decode_msg_7(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Binary Acknowledge - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_7_binary_acknowledge - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'mmsi1': get_mmsi(bit_arr, 40, 70), - 'mmsiseq1': get_int_from_data(70, 72), - 'mmsi2': get_mmsi(bit_arr, 72, 102), - 'mmsiseq2': get_int_from_data(102, 104), - 'mmsi3': get_mmsi(bit_arr, 104, 134), - 'mmsiseq3': get_int_from_data(134, 136), - 'mmsi4': get_mmsi(bit_arr, 136, 166), - 'mmsiseq4': get_int_from_data(166, 168) - } - - -def decode_msg_8(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Binary Acknowledge - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_8_binary_broadcast_message - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'dac': get_int_from_data(40, 50), - 'fid': get_int_from_data(50, 56), - 'data': bit_arr[56:].to01() - } - - -def decode_msg_9(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Standard SAR Aircraft Position Report - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_9_standard_sar_aircraft_position_report - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'alt': get_int_from_data(38, 50), - 'speed': get_int_from_data(50, 60), - 'accuracy': bit_arr[60], - 'lon': get_int_from_data(61, 89, signed=True) / 600000.0, - 'lat': get_int_from_data(89, 116, signed=True) / 600000.0, - 'course': get_int_from_data(116, 128) * 0.1, - 'second': get_int_from_data(128, 134), - 'dte': bit_arr[142], - 'assigned': bit_arr[146], - 'raim': bit_arr[147], - 'radio': get_int_from_data(148, 168) - } - - -def decode_msg_10(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - UTC/Date Inquiry - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_10_utc_date_inquiry - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'dest_mmsi': get_mmsi(bit_arr, 40, 70) - } - - -def decode_msg_11(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - UTC/Date Response - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_11_utc_date_response - """ - return decode_msg_4(bit_arr) - - -def decode_msg_12(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Addressed Safety-Related Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_12_addressed_safety_related_message - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'seqno': get_int_from_data(38, 40), - 'dest_mmsi': get_mmsi(bit_arr, 40, 70), - 'retransmit': bit_arr[70], - 'text': decode_bin_as_ascii6(bit_arr[72:]) - } - - -def decode_msg_13(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Identical to type 7 - """ - return decode_msg_7(bit_arr) - - -def decode_msg_14(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Safety-Related Broadcast Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_14_safety_related_broadcast_message - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'text': decode_bin_as_ascii6(bit_arr[40:]) - } - - -def decode_msg_15(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Interrogation - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_15_interrogation - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'mmsi1': get_mmsi(bit_arr, 40, 70), - 'type1_1': get_int_from_data(70, 76), - 'offset1_1': get_int_from_data(76, 88), - 'type1_2': get_int_from_data(90, 96), - 'offset1_2': get_int_from_data(96, 108), - 'mmsi2': get_mmsi(bit_arr, 110, 140), - 'type2_1': get_int_from_data(140, 146), - 'offset2_1': get_int_from_data(146, 157), - } - - -def decode_msg_16(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Assignment Mode Command - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_16_assignment_mode_command - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'mmsi1': get_mmsi(bit_arr, 40, 70), - 'offset1': get_int_from_data(70, 82), - 'increment1': get_int_from_data(82, 92), - 'mmsi2': get_mmsi(bit_arr, 92, 122), - 'offset2': get_int_from_data(122, 134), - 'increment2': get_int_from_data(134, 144) - } - - -def decode_msg_17(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - DGNSS Broadcast Binary Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_17_dgnss_broadcast_binary_message - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(6, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'lon': get_int_from_data(40, 58, signed=True), - 'lat': get_int_from_data(58, 75, signed=True), - 'data': get_int_from_data(80, 816) - } - - -def decode_msg_18(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Standard Class B CS Position Report - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_18_standard_class_b_cs_position_report - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(8, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'speed': get_int_from_data(46, 56) * 0.1, - 'accuracy': bit_arr[56], - 'lon': get_int_from_data(57, 85, signed=True) / 600000.0, - 'lat': get_int_from_data(85, 112, signed=True) / 600000.0, - 'course': get_int_from_data(112, 124) * 0.1, - 'heading': get_int_from_data(124, 133), - 'second': get_int_from_data(133, 139), - 'regional': get_int_from_data(139, 141), - 'cs': bit_arr[141], - 'display': bit_arr[142], - 'dsc': bit_arr[143], - 'band': bit_arr[144], - 'msg22': bit_arr[145], - 'assigned': bit_arr[146], - 'raim': bit_arr[147], - 'radio': get_int_from_data(148, len(bit_arr)), - } - - -def decode_msg_19(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Extended Class B CS Position Report - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_19_extended_class_b_cs_position_report - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(8, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'speed': get_int_from_data(46, 56) * 0.1, - 'accuracy': bit_arr[56], - 'lon': get_int_from_data(57, 85, signed=True) / 600000.0, - 'lat': get_int_from_data(85, 112, signed=True) / 600000.0, - 'course': get_int_from_data(112, 124) * 0.1, - 'heading': get_int_from_data(124, 133), - 'second': get_int_from_data(133, 139), - 'regional': get_int_from_data(139, 143), - 'shipname': decode_bin_as_ascii6(bit_arr[143:263]), - 'shiptype': ShipType(get_int_from_data(263, 271)), - 'to_bow': get_int_from_data(271, 280), - 'to_stern': get_int_from_data(280, 289), - 'to_port': get_int_from_data(289, 295), - 'to_starboard': get_int_from_data(295, 301), - 'epfd': EpfdType(get_int_from_data(301, 305)), - 'raim': bit_arr[305], - 'dte': bit_arr[306], - 'assigned': bit_arr[307], - } - - -def decode_msg_20(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Data Link Management Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_20_data_link_management_message - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(8, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - - 'offset1': get_int_from_data(40, 52), - 'number1': get_int_from_data(52, 56), - 'timeout1': get_int_from_data(56, 59), - 'increment1': get_int_from_data(59, 70), - - 'offset2': get_int_from_data(70, 82), - 'number2': get_int_from_data(82, 86), - 'timeout2': get_int_from_data(86, 89), - 'increment2': get_int_from_data(89, 100), - - 'offset3': get_int_from_data(100, 112), - 'number3': get_int_from_data(112, 116), - 'timeout3': get_int_from_data(116, 119), - 'increment3': get_int_from_data(110, 130), - - 'offset4': get_int_from_data(130, 142), - 'number4': get_int_from_data(142, 146), - 'timeout4': get_int_from_data(146, 149), - 'increment4': get_int_from_data(149, 160), - } - - -def decode_msg_21(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Aid-to-Navigation Report - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_21_aid_to_navigation_report - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(8, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - - 'aid_type': NavAid(get_int_from_data(38, 43)), - 'name': decode_bin_as_ascii6(bit_arr[43:163]), - 'accuracy': bit_arr[163], - - 'lon': get_int_from_data(164, 192, signed=True) / 600000.0, - 'lat': get_int_from_data(192, 219, signed=True) / 600000.0, - - 'to_bow': get_int_from_data(219, 228), - 'to_stern': get_int_from_data(228, 237), - 'to_port': get_int_from_data(237, 243), - 'to_starboard': get_int_from_data(243, 249), - - 'epfd': EpfdType(get_int_from_data(249, 253)), - 'second': get_int_from_data(253, 259), - 'off_position': bit_arr[259], - 'regional': get_int_from_data(260, 268), - 'raim': bit_arr[268], - 'virtual_aid': bit_arr[269], - 'assigned': bit_arr[270], - 'name_extension': decode_bin_as_ascii6(bit_arr[272:]), - } - - -def decode_msg_22(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Channel Management - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_22_channel_management - """ - get_int_from_data = partial(get_int, bit_arr) - data = { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(8, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - - 'channel_a': get_int_from_data(40, 52), - 'channel_b': get_int_from_data(52, 64), - 'txrx': get_int_from_data(64, 68), - 'power': bit_arr[68], - 'addressed': bit_arr[139], - 'band_a': bit_arr[140], - 'band_b': bit_arr[141], - 'zonesize': get_int_from_data(142, 145), - } - - d: Dict[str, Any] = {} - if data['addressed']: - # Addressed - d = { - 'dest1': get_mmsi(bit_arr, 69, 99), - 'dest2': get_mmsi(bit_arr, 104, 134), - } - else: - # Broadcast - d = { - 'ne_lon': get_int_from_data(69, 87, signed=True) * 0.1, - 'ne_lat': get_int_from_data(87, 104, signed=True) * 0.1, - 'sw_lon': get_int_from_data(104, 122, signed=True) * 0.1, - 'sw_lat': get_int_from_data(122, 139, signed=True) * 0.1, - } - - data.update(d) - return data - - -def decode_msg_23(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Group Assignment Command - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_23_group_assignment_command - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(8, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - - 'ne_lon': get_int_from_data(40, 58, signed=True) * 0.1, - 'ne_lat': get_int_from_data(58, 75, signed=True) * 0.1, - 'sw_lon': get_int_from_data(75, 93, signed=True) * 0.1, - 'sw_lat': get_int_from_data(93, 110, signed=True) * 0.1, - - 'station_type': StationType(get_int_from_data(110, 114)), - 'shiptype': ShipType(get_int_from_data(114, 122)), - 'txrx': TransmitMode(get_int_from_data(144, 146)), - 'interval': StationIntervals(get_int_from_data(146, 150)), - 'quiet': get_int_from_data(150, 154), - } - - -def decode_msg_24(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Static Data Report - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_24_static_data_report - """ - get_int_from_data = partial(get_int, bit_arr) - data = { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(8, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - 'partno': get_int_from_data(38, 40) - } - - d: Dict[str, Any] - if not data['partno']: - # Part A - d = { - 'shipname': decode_bin_as_ascii6(bit_arr[40: 160]) - } - else: - # Part B - d = { - 'shiptype': ShipType(get_int_from_data(40, 48)), - 'vendorid': decode_bin_as_ascii6(bit_arr[48: 66]), - 'model': get_int_from_data(66, 70), - 'serial': get_int_from_data(70, 90), - 'callsign': decode_bin_as_ascii6(bit_arr[90: 132]), - 'to_bow': get_int_from_data(132, 141), - 'to_stern': get_int_from_data(141, 150), - 'to_port': get_int_from_data(150, 156), - 'to_starboard': get_int_from_data(156, 162), - 'mothership_mmsi': get_mmsi(bit_arr, 132, 162) - } - data.update(d) - return data - - -def decode_msg_25(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Single Slot Binary Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_25_single_slot_binary_message - - NOTE: This message type is quite uncommon and - I was not able find any real world occurrence of the type. - Also documentation seems to vary. Use with caution. - """ - get_int_from_data = partial(get_int, bit_arr) - data: Dict[str, Any] = { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(8, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - - 'addressed': bit_arr[38], - 'structured': bit_arr[39], - } - - d: Dict[str, Any] - if data['addressed']: - d = { - 'dest_mmsi': get_mmsi(bit_arr, 40, 70), - } - data.update(d) - - lo_ix = 40 if data['addressed'] else 70 - hi_ix = lo_ix + 16 - - if data['structured']: - d = { - 'app_id': get_int_from_data(lo_ix, hi_ix), - 'data': bit_arr[hi_ix:].to01() - } - else: - d = { - 'data': bit_arr[lo_ix:].to01() - } - data.update(d) - return data - - -def decode_msg_26(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Multiple Slot Binary Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_26_multiple_slot_binary_message - - NOTE: This message type is quite uncommon and - I was not able find any real world occurrence of the type. - Also documentation seems to vary. Use with caution. - """ - get_int_from_data = partial(get_int, bit_arr) - radio_status_offset = len(bit_arr) - 20 - - data = { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(8, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - - 'addressed': bit_arr[38], - 'structured': bit_arr[39], - 'radio': get_int_from_data(radio_status_offset, len(bit_arr)) - } - - d: Dict[str, Any] - if data['addressed']: - d = { - 'dest_mmsi': get_mmsi(bit_arr, 40, 70), - } - data.update(d) - - lo_ix = 40 if data['addressed'] else 70 - hi_ix = lo_ix + 16 - - if data['structured']: - d = { - 'app_id': get_int_from_data(lo_ix, hi_ix), - 'data': bit_arr[hi_ix:radio_status_offset].to01() - } - else: - d = { - 'data': bit_arr[lo_ix:radio_status_offset].to01() - } - - data.update(d) - return data - - -def decode_msg_27(bit_arr: bitarray.bitarray) -> Dict[str, Any]: - """ - Long Range AIS Broadcast message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_27_long_range_ais_broadcast_message - """ - get_int_from_data = partial(get_int, bit_arr) - return { - 'type': get_int_from_data(0, 6), - 'repeat': get_int_from_data(8, 8), - 'mmsi': get_mmsi(bit_arr, 8, 38), - - 'accuracy': bit_arr[38], - 'raim': bit_arr[39], - 'status': NavigationStatus(get_int_from_data(40, 44)), - 'lon': get_int_from_data(44, 62, signed=True) / 600.0, - 'lat': get_int_from_data(62, 79, signed=True) / 600.0, - 'speed': get_int_from_data(79, 85), - 'course': get_int_from_data(85, 94), - 'gnss': bit_arr[94], - } - - -# Decoding Lookup Table -DECODE_MSG = [ - decode_msg_1, # there are messages with a zero (0) as an id. these seem to be the same as type 1 messages - decode_msg_1, - decode_msg_2, - decode_msg_3, - decode_msg_4, - decode_msg_5, - decode_msg_6, - decode_msg_7, - decode_msg_8, - decode_msg_9, - decode_msg_10, - decode_msg_11, - decode_msg_12, - decode_msg_13, - decode_msg_14, - decode_msg_15, - decode_msg_16, - decode_msg_17, - decode_msg_18, - decode_msg_19, - decode_msg_20, - decode_msg_21, - decode_msg_22, - decode_msg_23, - decode_msg_24, - decode_msg_25, - decode_msg_26, - decode_msg_27, -] - - -def _decode(msg: "messages.NMEAMessage") -> Dict[str, Any]: - """ - Decodes a given NMEA message. - """ - try: - return DECODE_MSG[msg.ais_id](msg.bit_array) - except IndexError as e: - raise UnknownMessageException(f"The message {msg} is not currently supported!") from e - - -def decode(msg: "messages.NMEAMessage") -> Dict[str, Any]: - """ - Decodes a given message. - - @param msg: A object of type NMEAMessage to decode - """ - return _decode(msg) - - -def decode_msg(*args: Union[str, bytes]) -> Dict[str, Any]: - """ - Decode single message. - - This method is ONLY meant to decode a SINGLE (multiline) message. - Pass every part of a single as an argument. - - @param args: A AIS message, that can be either bytes or str (UTF-8) encoded. - For multiline messages, pass all parts. - @return: A dictionary of the decoded key-value pairs. - - """ - # Make everything bytes - message_as_bytes = tuple(msg.encode('utf-8') if isinstance(msg, str) else msg for msg in args) - +import typing + +from pyais.exceptions import UnknownMessageException, TooManyMessagesException, MissingMultipartMessageException +from pyais.messages import MessageType1, MessageType2, MessageType3, MessageType4, MessageType5, MessageType6, \ + MessageType7, MessageType8, MessageType9, MessageType10, MessageType11, MessageType12, MessageType13, MessageType14, \ + MessageType15, MessageType16, MessageType17, MessageType18, MessageType19, MessageType20, MessageType21, \ + MessageType22, MessageType23, MessageType24, MessageType25, MessageType26, MessageType27, NMEAMessage + +DECODE_MSG = { + 0: MessageType1, # there are messages with a zero (0) as an id. these seem to be the same as type 1 messages + 1: MessageType1, + 2: MessageType2, + 3: MessageType3, + 4: MessageType4, + 5: MessageType5, + 6: MessageType6, + 7: MessageType7, + 8: MessageType8, + 9: MessageType9, + 10: MessageType10, + 11: MessageType11, + 12: MessageType12, + 13: MessageType13, + 14: MessageType14, + 15: MessageType15, + 16: MessageType16, + 17: MessageType17, + 18: MessageType18, + 19: MessageType19, + 20: MessageType20, + 21: MessageType21, + 22: MessageType22, + 23: MessageType23, + 24: MessageType24, + 25: MessageType25, + 26: MessageType26, + 27: MessageType27, +} + + +def _assemble_messages(*args: bytes) -> NMEAMessage: # Convert bytes into NMEAMessage and remember fragment_count and fragment_numbers - temp: List[messages.NMEAMessage] = [] - frags: List[int] = [] + temp: typing.List[NMEAMessage] = [] + frags: typing.List[int] = [] frag_cnt: int = 1 - for msg in message_as_bytes: - nmea = messages.NMEAMessage(msg) + for msg in args: + nmea = NMEAMessage(msg) temp.append(nmea) frags.append(nmea.fragment_number) frag_cnt = nmea.fragment_count # Make sure provided parts assemble a single (multiline message) - if len(message_as_bytes) > frag_cnt: - raise TooManyMessagesException(f"Got {len(message_as_bytes)} messages, but fragment count is {frag_cnt}") + if len(args) > frag_cnt: + raise TooManyMessagesException(f"Got {len(args)} messages, but fragment count is {frag_cnt}") # Make sure all parts of a multipart message are provided diff = [x for x in range(1, frag_cnt + 1) if x not in frags] @@ -735,7 +59,14 @@ def decode_msg(*args: Union[str, bytes]) -> Dict[str, Any]: raise MissingMultipartMessageException(f"Missing fragment numbers: {diff}") # Assemble temporary messages - final = messages.NMEAMessage.assemble_from_iterable(temp) + final = NMEAMessage.assemble_from_iterable(temp) + return final - # Decode - return final.decode(silent=False).content # type: ignore + +def decode(*args: typing.Union[str, bytes]): + parts = tuple(msg.encode('utf-8') if isinstance(msg, str) else msg for msg in args) + nmea = _assemble_messages(*parts) + try: + return DECODE_MSG[nmea.ais_id].from_bitarray(nmea.bit_array) + except IndexError as e: + raise UnknownMessageException(f"The message {nmea} is not supported!") from e diff --git a/pyais/encode.py b/pyais/encode.py index 3d25c9e..1739f7b 100644 --- a/pyais/encode.py +++ b/pyais/encode.py @@ -1,1052 +1,16 @@ -import abc import math import typing -import attr -import bitarray - -from pyais import NMEAMessage -from pyais.util import chunks, from_bytes, compute_checksum +from pyais.messages import MessageType1, MessageType27, MessageType26, MessageType25, MessageType24, MessageType23, \ + MessageType22, MessageType21, MessageType20, MessageType19, MessageType18, MessageType17, MessageType16, \ + MessageType15, MessageType14, MessageType13, MessageType12, MessageType11, MessageType10, MessageType9, \ + MessageType2, MessageType3, MessageType4, MessageType5, MessageType6, MessageType7, MessageType8, Payload +from pyais.util import chunks, compute_checksum # Types DATA_DICT = typing.Dict[str, typing.Union[str, int, float, bytes, bool]] AIS_SENTENCES = typing.List[str] -# https://gpsd.gitlab.io/gpsd/AIVDM.html#_aivdmaivdo_payload_armoring -PAYLOAD_ARMOR = { - 0: '0', 1: '1', 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: ':', - 11: ';', 12: '<', 13: '=', 14: '>', 15: '?', 16: '@', 17: 'A', 18: 'B', 19: 'C', 20: 'D', - 21: 'E', 22: 'F', 23: 'G', 24: 'H', 25: 'I', 26: 'J', 27: 'K', 28: 'L', 29: 'M', 30: 'N', - 31: 'O', 32: 'P', 33: 'Q', 34: 'R', 35: 'S', 36: 'T', 37: 'U', 38: 'V', 39: 'W', 40: '`', - 41: 'a', 42: 'b', 43: 'c', 44: 'd', 45: 'e', 46: 'f', 47: 'g', 48: 'h', 49: 'i', 50: 'j', - 51: 'k', 52: 'l', 53: 'm', 54: 'n', 55: 'o', 56: 'p', 57: 'q', 58: 'r', 59: 's', 60: 't', - 61: 'u', 62: 'v', 63: 'w' -} - -# https://gpsd.gitlab.io/gpsd/AIVDM.html#_ais_payload_data_types -SIX_BIT_ENCODING = { - '@': 0, 'A': 1, 'B': 2, 'C': 3, 'D': 4, 'E': 5, 'F': 6, 'G': 7, 'H': 8, 'I': 9, 'J': 10, - 'K': 11, 'L': 12, 'M': 13, 'N': 14, 'O': 15, 'P': 16, 'Q': 17, 'R': 18, 'S': 19, 'T': 20, - 'U': 21, 'V': 22, 'W': 23, 'X': 24, 'Y': 25, 'Z': 26, '[': 27, '\\': 28, ']': 29, '^': 30, - '_': 31, ' ': 32, '!': 33, '"': 34, '#': 35, '$': 36, '%': 37, '&': 38, '\'': 39, '(': 40, - ')': 41, '*': 42, '+': 43, ',': 44, '-': 45, '.': 46, '/': 47, '0': 48, '1': 49, '2': 50, - '3': 51, '4': 52, '5': 53, '6': 54, '7': 55, '8': 56, '9': 57, ':': 58, ';': 59, '<': 60, - '=': 61, '>': 62, '?': 63 -} - - -def to_six_bit(char: str) -> str: - """ - Encode a single character as six-bit bitstring. - @param char: The character to encode - @return: The six-bit representation as string - """ - char = char.upper() - try: - encoding = SIX_BIT_ENCODING[char] - return f"{encoding:06b}" - except KeyError: - raise ValueError(f"received char '{char}' that cant be encoded") - - -def encode_ascii_6(bits: bitarray.bitarray) -> typing.Tuple[str, int]: - """ - Transform the bitarray to an ASCII-encoded bit vector. - Each character represents six bits of data. - @param bits: The bitarray to convert to an ASCII-encoded bit vector. - @return: ASCII-encoded bit vector and the number of fill bits required to pad the data payload to a 6 bit boundary. - """ - out = "" - chunk: bitarray.bitarray - padding = 0 - for chunk in chunks(bits, 6): # type:ignore - padding = 6 - len(chunk) - num = from_bytes(chunk.tobytes()) >> 2 - if padding: - num >> padding - armor = PAYLOAD_ARMOR[num] - out += armor - return out, padding - - -def int_to_bytes(val: typing.Union[int, bytes]) -> int: - """ - Convert a bytes object to an integer. Byteorder is big. - - @param val: A bytes object to convert to an int. If the value is already an int, this is a NO-OP. - @return: Integer representation of `val` - """ - if isinstance(val, int): - return val - return int.from_bytes(val, 'big') - - -def int_to_bin(val: typing.Union[int, bool], width: int) -> bitarray.bitarray: - """ - Convert an integer or boolean value to binary. If the value is too great to fit into - `width` bits, the maximum possible number that still fits is used. - - @param val: Any integer or boolean value. - @param width: The bit width. If less than width bits are required, leading zeros are added. - @return: The binary representation of value with exactly width bits. Type is bitarray. - """ - # Compute the total number of bytes required to hold up to `width` bits. - n_bytes, mod = divmod(width, 8) - if mod > 0: - n_bytes += 1 - - # If the value is too big, return a bitarray of all 1's - mask = (1 << width) - 1 - if val >= mask: - return bitarray.bitarray('1' * width) - - bits = bitarray.bitarray(endian='big') - bits.frombytes(val.to_bytes(n_bytes, 'big', signed=True)) - return bits[8 - mod if mod else 0:] - - -def str_to_bin(val: str, width: int) -> bitarray.bitarray: - """ - Convert a string value to binary using six-bit ASCII encoding up to `width` chars. - - @param val: The string to first convert to six-bit ASCII and then to binary. - @param width: The width of the full string. If the string has fewer characters than width, trailing '@' are added. - @return: The binary representation of value with exactly width bits. Type is bitarray. - """ - out = bitarray.bitarray(endian='big') - - # Each char will be converted to a six-bit binary vector. - # Therefore, the total number of chars is floor(WIDTH / 6). - num_chars = int(width / 6) - - # Add trailing '@' if the string is shorter than `width` - for _ in range(num_chars - len(val)): - val += "@" - - # Encode AT MOST width characters - for char in val[:num_chars]: - # Covert each char to six-bit ASCII vector - txt = to_six_bit(char) - out += bitarray.bitarray(txt) - - return out - - -def bit_field(width: int, d_type: typing.Type[typing.Any], - from_converter: typing.Optional[typing.Callable[[typing.Any], typing.Any]] = None, - to_converter: typing.Optional[typing.Callable[[typing.Any], typing.Any]] = None, - default: typing.Optional[typing.Any] = None) -> typing.Any: - """ - Simple wrapper around the attr.ib interface to be used in conjunction with the Payload class. - - @param width: The bit-width of the field. - @param d_type: The datatype of the fields value. - @param from_converter: Optional converter function called **before** encoding - @param to_converter: Optional converter function called **after** decoding - @param default: Optional default value to be used when no value is explicitly passed. - @return: An attr.ib field instance. - """ - return attr.ib( - metadata={ - 'width': width, - 'd_type': d_type, - 'from_converter': from_converter, - 'to_converter': to_converter, - 'default': default, - }, - ) - - -@attr.s(slots=True) -class Payload(abc.ABC): - """ - Payload class - -------------- - This class serves as an abstract base class for all messages. - Each message shall inherit from Payload and define it's set of field using the `bit_field` method. - """ - - @classmethod - def fields(cls) -> typing.Tuple[typing.Any]: - """ - A list of all fields that were added to this class using attrs. - """ - return attr.fields(cls) # type:ignore - - def to_bitarray(self) -> bitarray.bitarray: - """ - Convert a payload to binary. - """ - out = bitarray.bitarray() - for field in self.fields(): - width = field.metadata['width'] - d_type = field.metadata['d_type'] - converter = field.metadata['from_converter'] - - val = getattr(self, field.name) - val = converter(val) if converter is not None else val - val = d_type(val) - - if d_type == int or d_type == bool: - bits = int_to_bin(val, width) - elif d_type == str: - bits = str_to_bin(val, width) - else: - raise ValueError() - bits = bits[:width] - out += bits - - return out - - def encode(self) -> typing.Tuple[str, int]: - """ - Encode a payload as an ASCII encoded bit vector. The second returned value is the number of fill bits. - """ - bit_arr = self.to_bitarray() - return encode_ascii_6(bit_arr) - - @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": - """ - Create a new instance of each Payload class. - @param kwargs: A set of keywords. For each field of `cls` a keyword with the same - name is searched.If no matching keyword argument was provided the - default value will be used - if one is available. - @return: - """ - args = {} - - # Iterate over each field of the payload class and check for a matching keyword argument. - # If no matching kwarg was provided use a default value - for field in cls.fields(): - key = field.name - try: - args[key] = kwargs[key] - except KeyError: - # Check if a default value was provided - default = field.metadata['default'] - if default is not None: - args[key] = default - return cls(**args) - - @classmethod - def from_bytes(cls, byte_str: bytes) -> "MessageType1": - # Split the message & get the payload as a bit sequence - msg = NMEAMessage.from_bytes(byte_str) - bit_arr = msg.bit_array - - cur = 0 - - kwargs = {} - - # Iterate over the bits until the last bit of the bitarray or all fields are fully decoded - for field in cls.fields(): - width = field.metadata['width'] - d_type = field.metadata['d_type'] - converter = field.metadata['to_converter'] - - end = min(len(bit_arr), cur + width) - bits = bit_arr[cur: cur + width] - - # Get the correct data type and decoding function - if d_type == int or d_type == bool: - shift = 8 - (width % 8) - val = from_bytes(bits) >> shift - else: - raise ValueError() - - val = converter(val) if converter is not None else val - kwargs[field.name] = val - - if end >= len(bit_arr): - break - - cur = end - - return cls(**kwargs) - - -# -# Conversion functions -# - -def from_speed(v: int) -> float: - return float(v) * 10.0 - - -def to_speed(v: int) -> float: - return v / 10.0 - - -def from_lat_lon(v: int) -> float: - return float(v) * 600000.0 - - -def to_lat_lon(v: int) -> float: - return float(v) / 600000.0 - - -def from_course(v: int) -> float: - return float(v) * 10.0 - - -def to_course(v: int) -> float: - return v / 10.0 - - -@attr.s(slots=True) -class MessageType1(Payload): - """ - AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a - """ - msg_type = bit_field(6, int, default=1) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - status = bit_field(4, int, default=0) - turn = bit_field(8, int, default=0) - speed = bit_field(10, int, from_converter=from_speed, to_converter=to_speed, default=0) - accuracy = bit_field(1, int, default=0) - lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, default=0) - lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, default=0) - course = bit_field(12, int, from_converter=from_course, to_converter=to_course, default=0) - heading = bit_field(9, int, default=0) - second = bit_field(6, int, default=0) - maneuver = bit_field(2, int, default=0) - spare = bit_field(3, int, default=0) - raim = bit_field(1, int, default=0) - radio = bit_field(19, int, default=0) - - -class MessageType2(MessageType1): - """ - AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a - """ - msg_type = bit_field(6, int, default=2) - - -class MessageType3(MessageType1): - """ - AIS Vessel position report using ITDMA (Incremental Time Division Multiple Access) - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a - """ - msg_type = bit_field(6, int, default=3) - - -@attr.s(slots=True) -class MessageType4(Payload): - """ - AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_4_base_station_report - """ - msg_type = bit_field(6, int, default=4) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - year = bit_field(14, int, default=1970) - month = bit_field(4, int, default=1) - day = bit_field(5, int, default=1) - hour = bit_field(5, int, default=0) - minute = bit_field(6, int, default=0) - second = bit_field(6, int, default=0) - accuracy = bit_field(1, int, default=0) - lon = bit_field(28, int, from_converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, from_converter=lambda v: float(v) * 600000.0, default=0) - epfd = bit_field(4, int, default=0) - spare = bit_field(10, int, default=0) - raim = bit_field(1, int, default=0) - radio = bit_field(19, int, default=0) - - -@attr.s(slots=True) -class MessageType5(Payload): - """ - Static and Voyage Related Data - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_5_static_and_voyage_related_data - """ - msg_type = bit_field(6, int, default=5) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - ais_version = bit_field(2, int, default=0) - imo = bit_field(30, int, default=0) - callsign = bit_field(42, str, default='') - shipname = bit_field(120, str, default='') - shiptype = bit_field(8, int, default=0) - to_bow = bit_field(9, int, default=0) - to_stern = bit_field(9, int, default=0) - to_port = bit_field(6, int, default=0) - to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0) - month = bit_field(4, int, default=0) - day = bit_field(5, int, default=0) - hour = bit_field(5, int, default=0) - minute = bit_field(6, int, default=0) - draught = bit_field(8, int, from_converter=lambda v: float(v) * 10.0, default=0) - destination = bit_field(120, str, default='') - dte = bit_field(1, int, default=0) - spare = bit_field(1, int, default=0) - - -@attr.s(slots=True) -class MessageType6(Payload): - """ - Binary Addresses Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_4_base_station_report - """ - msg_type = bit_field(6, int, default=6) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - seqno = bit_field(2, int, default=0) - dest_mmsi = bit_field(30, int) - retransmit = bit_field(1, int, default=0) - spare = bit_field(1, int, default=0) - dac = bit_field(10, int, default=0) - fid = bit_field(6, int, default=0) - data = bit_field(920, int, default=0, from_converter=int_to_bytes) - - -@attr.s(slots=True) -class MessageType7(Payload): - """ - Binary Acknowledge - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_7_binary_acknowledge - """ - msg_type = bit_field(6, int, default=7) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare = bit_field(2, int, default=0) - mmsi1 = bit_field(30, int, default=0) - mmsiseq1 = bit_field(2, int, default=0) - mmsi2 = bit_field(30, int, default=0) - mmsiseq2 = bit_field(2, int, default=0) - mmsi3 = bit_field(30, int, default=0) - mmsiseq3 = bit_field(2, int, default=0) - mmsi4 = bit_field(30, int, default=0) - mmsiseq4 = bit_field(2, int, default=0) - - -@attr.s(slots=True) -class MessageType8(Payload): - """ - Binary Acknowledge - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_8_binary_broadcast_message - """ - msg_type = bit_field(6, int, default=8) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare = bit_field(2, int, default=0) - dac = bit_field(10, int, default=0) - fid = bit_field(6, int, default=0) - data = bit_field(952, int, default=0, from_converter=int_to_bytes) - - -@attr.s(slots=True) -class MessageType9(Payload): - """ - Standard SAR Aircraft Position Report - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_9_standard_sar_aircraft_position_report - """ - msg_type = bit_field(6, int, default=9) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - alt = bit_field(12, int, default=0) - speed = bit_field(10, int, default=0) - accuracy = bit_field(1, int, default=0) - lon = bit_field(28, int, from_converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, from_converter=lambda v: float(v) * 600000.0, default=0) - course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) - second = bit_field(6, int, default=0) - reserved = bit_field(8, int, default=0) - dte = bit_field(1, int, default=0) - spare = bit_field(3, int, default=0) - assigned = bit_field(1, int, default=0) - raim = bit_field(1, int, default=0) - radio = bit_field(20, int, default=0) - - -@attr.s(slots=True) -class MessageType10(Payload): - """ - UTC/Date Inquiry - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_10_utc_date_inquiry - """ - msg_type = bit_field(6, int, default=10) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare_1 = bit_field(2, int, default=0) - dest_mmsi = bit_field(30, int) - spare_2 = bit_field(2, int, default=0) - - -class MessageType11(MessageType4): - """ - UTC/Date Response - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_11_utc_date_response - """ - msg_type = bit_field(6, int, default=11) - - -@attr.s(slots=True) -class MessageType12(Payload): - """ - Addressed Safety-Related Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_12_addressed_safety_related_message - """ - msg_type = bit_field(6, int, default=12) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - seqno = bit_field(2, int, default=0) - dest_mmsi = bit_field(30, int) - retransmit = bit_field(1, int, default=0) - spare = bit_field(1, int, default=0) - text = bit_field(936, str, default='') - - -class MessageType13(MessageType7): - """ - Identical to type 7 - """ - msg_type = bit_field(6, int, default=13) - - -@attr.s(slots=True) -class MessageType14(Payload): - """ - Safety-Related Broadcast Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_14_safety_related_broadcast_message - """ - msg_type = bit_field(6, int, default=14) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare = bit_field(2, int, default=0) - text = bit_field(968, str, default='') - - -@attr.s(slots=True) -class MessageType15(Payload): - """ - Interrogation - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_15_interrogation - """ - msg_type = bit_field(6, int, default=15) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare_1 = bit_field(2, int, default=0) - mmsi1 = bit_field(30, int, default=0) - type1_1 = bit_field(6, int, default=0) - offset1_1 = bit_field(12, int, default=0) - spare_2 = bit_field(2, int, default=0) - type1_2 = bit_field(6, int, default=0) - offset1_2 = bit_field(12, int, default=0) - spare_3 = bit_field(2, int, default=0) - mmsi2 = bit_field(30, int, default=0) - type2_1 = bit_field(6, int, default=0) - offset2_1 = bit_field(12, int, default=0) - spare_4 = bit_field(2, int, default=0) - - -@attr.s(slots=True) -class MessageType16(Payload): - """ - Assignment Mode Command - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_16_assignment_mode_command - """ - msg_type = bit_field(6, int, default=16) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare = bit_field(2, int, default=0) - - mmsi1 = bit_field(30, int, default=0) - offset1 = bit_field(12, int, default=0) - increment1 = bit_field(10, int, default=0) - - mmsi2 = bit_field(30, int, default=0) - offset2 = bit_field(12, int, default=0) - increment2 = bit_field(10, int, default=0) - - -@attr.s(slots=True) -class MessageType17(Payload): - """ - DGNSS Broadcast Binary Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_17_dgnss_broadcast_binary_message - """ - msg_type = bit_field(6, int, default=17) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare_1 = bit_field(2, int, default=0) - lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) - lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) - spare_2 = bit_field(5, int, default=0) - data = bit_field(736, int, default=0, from_converter=int_to_bytes) - - -@attr.s(slots=True) -class MessageType18(Payload): - """ - Standard Class B CS Position Report - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_18_standard_class_b_cs_position_report - """ - msg_type = bit_field(6, int, default=18) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - reserved = bit_field(8, int, default=0) - speed = bit_field(10, int, default=0) - accuracy = bit_field(1, int, default=0) - lon = bit_field(28, int, from_converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, from_converter=lambda v: float(v) * 600000.0, default=0) - course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) - heading = bit_field(9, int, default=0) - second = bit_field(6, int, default=0) - reserved_2 = bit_field(2, int, default=0) - cs = bit_field(1, bool, default=0) - display = bit_field(1, bool, default=0) - dsc = bit_field(1, bool, default=0) - band = bit_field(1, bool, default=0) - msg22 = bit_field(1, bool, default=0) - assigned = bit_field(1, bool, default=0) - raim = bit_field(1, bool, default=0) - radio = bit_field(20, int, default=0) - - -@attr.s(slots=True) -class MessageType19(Payload): - """ - Extended Class B CS Position Report - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_19_extended_class_b_cs_position_report - """ - msg_type = bit_field(6, int, default=19) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - reserved = bit_field(8, int, default=0) - speed = bit_field(10, int, from_converter=lambda v: float(v) * 10.0, default=0) - accuracy = bit_field(1, int, default=0) - lon = bit_field(28, int, from_converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, from_converter=lambda v: float(v) * 600000.0, default=0) - course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) - heading = bit_field(9, int, default=0) - second = bit_field(6, int, default=0) - regional = bit_field(4, int, default=0) - shipname = bit_field(120, str, default='') - shiptype = bit_field(8, int, default=0) - to_bow = bit_field(9, int, default=0) - to_stern = bit_field(9, int, default=0) - to_port = bit_field(6, int, default=0) - to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0) - raim = bit_field(1, bool, default=0) - dte = bit_field(1, bool, default=0) - assigned = bit_field(1, int, default=0) - spare = bit_field(4, int, default=0) - - -@attr.s(slots=True) -class MessageType20(Payload): - """ - Data Link Management Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_20_data_link_management_message - """ - msg_type = bit_field(6, int, default=20) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare = bit_field(2, int, default=0) - - offset1 = bit_field(12, int, default=0) - number1 = bit_field(4, int, default=0) - timeout1 = bit_field(3, int, default=0) - increment1 = bit_field(11, int, default=0) - - offset2 = bit_field(12, int, default=0) - number2 = bit_field(4, int, default=0) - timeout2 = bit_field(3, int, default=0) - increment2 = bit_field(11, int, default=0) - - offset3 = bit_field(12, int, default=0) - number3 = bit_field(4, int, default=0) - timeout3 = bit_field(3, int, default=0) - increment3 = bit_field(11, int, default=0) - - offset4 = bit_field(12, int, default=0) - number4 = bit_field(4, int, default=0) - timeout4 = bit_field(3, int, default=0) - increment4 = bit_field(11, int, default=0) - - -@attr.s(slots=True) -class MessageType21(Payload): - """ - Aid-to-Navigation Report - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_21_aid_to_navigation_report - """ - msg_type = bit_field(6, int, default=21) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - aid_type = bit_field(5, int, default=0) - shipname = bit_field(120, str, default='') - accuracy = bit_field(1, bool, default=0) - lon = bit_field(28, int, from_converter=lambda v: float(v) * 600000.0, default=0) - lat = bit_field(27, int, from_converter=lambda v: float(v) * 600000.0, default=0) - to_bow = bit_field(9, int, default=0) - to_stern = bit_field(9, int, default=0) - to_port = bit_field(6, int, default=0) - to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0) - second = bit_field(6, int, default=0) - off_position = bit_field(1, bool, default=0) - regional = bit_field(8, int, default=0) - raim = bit_field(1, bool, default=0) - virtual_aid = bit_field(1, bool, default=0) - assigned = bit_field(1, bool, default=0) - spare = bit_field(1, int, default=0) - name_ext = bit_field(88, str, default='') - - -@attr.s(slots=True) -class MessageType22Addressed(Payload): - """ - Channel Management - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_22_channel_management - """ - msg_type = bit_field(6, int, default=22) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare_1 = bit_field(2, int, default=0) # 40 bits - - channel_a = bit_field(12, int, default=0) - channel_b = bit_field(12, int, default=0) - txrx = bit_field(4, int, default=0) - power = bit_field(1, bool, default=0) # 69 bits - - # If it is addressed (addressed field is 1), - # the same span of data is interpreted as two 30-bit MMSIs - # beginning at bit offsets 69 and 104 respectively. - dest1 = bit_field(30, int, default=0) - empty_1 = bit_field(5, int, default=0) - dest2 = bit_field(30, int, default=0) - empty_2 = bit_field(5, int, default=0) - - addressed = bit_field(1, bool, default=0) - band_a = bit_field(1, bool, default=0) - band_b = bit_field(1, bool, default=0) - zonesize = bit_field(3, int, default=0) - spare_2 = bit_field(23, int, default=0) - - -@attr.s(slots=True) -class MessageType22Broadcast(Payload): - """ - Channel Management - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_22_channel_management - """ - msg_type = bit_field(6, int, default=22) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare_1 = bit_field(2, int, default=0) - - channel_a = bit_field(12, int, default=0) - channel_b = bit_field(12, int, default=0) - txrx = bit_field(4, int, default=0) - power = bit_field(1, bool, default=0) - - # If the message is broadcast (addressed field is 0), - # the ne_lon, ne_lat, sw_lon, and sw_lat fields are the - # corners of a rectangular jurisdiction area over which control parameter - ne_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) - ne_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) - sw_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) - sw_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) - - addressed = bit_field(1, bool, default=0) - band_a = bit_field(1, bool, default=0) - band_b = bit_field(1, bool, default=0) - zonesize = bit_field(3, int, default=0) - spare_2 = bit_field(23, int, default=0) - - -class MessageType22(Payload): - """ - Type 22 messages are different from other messages: - The encoding differs depending on the `addressed` field. If the message is broadcast - (addressed field is 0), the ne_lon, ne_lat, sw_lon, and sw_lat fields are the - corners of a rectangular jurisdiction area over which control parameters are to - be set. If it is addressed (addressed field is 1), - the same span of data is interpreted as two 30-bit MMSIs beginning - at bit offsets 69 and 104 respectively. - """ - - @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": - if kwargs.get('addressed', False): - return MessageType22Addressed.create(**kwargs) - else: - return MessageType22Broadcast.create(**kwargs) - - -@attr.s(slots=True) -class MessageType23(Payload): - """ - Group Assignment Command - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_23_group_assignment_command - """ - msg_type = bit_field(6, int, default=23) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - spare_1 = bit_field(2, int, default=0) - - ne_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) - ne_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) - sw_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) - sw_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) - - station_type = bit_field(4, int, default=0) - ship_type = bit_field(8, int, default=0) - spare_2 = bit_field(22, int, default=0) - - txrx = bit_field(2, int, default=0) - interval = bit_field(4, int, default=0) - quiet = bit_field(4, int, default=0) - spare_3 = bit_field(6, int, default=0) - - -@attr.s(slots=True) -class MessageType24PartA(Payload): - msg_type = bit_field(6, int, default=24) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - partno = bit_field(2, int, default=0) - shipname = bit_field(120, str, default='') - spare_1 = bit_field(8, int, default=0) - - -@attr.s(slots=True) -class MessageType24PartB(Payload): - msg_type = bit_field(6, int, default=24) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - partno = bit_field(2, int, default=0) - shiptype = bit_field(8, int, default=0) - vendorid = bit_field(18, str, default=0) - model = bit_field(4, int, default=0) - serial = bit_field(20, int, default=0) - callsign = bit_field(42, str, default='') - - to_bow = bit_field(9, int, default=0) - to_stern = bit_field(9, int, default=0) - to_port = bit_field(6, int, default=0) - to_starboard = bit_field(6, int, default=0) - - spare_2 = bit_field(6, int, default=0) - - -class MessageType24(Payload): - """ - Static Data Report - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_24_static_data_report - - Just like message type 22, this message encodes different fields depending - on the `partno` field. - If the Part Number field is 0, the rest of the message is interpreted as a Part A; if it is 1, - the rest of the message is interpreted as a Part B; - """ - - @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": - partno: int = int(kwargs.get('partno', 0)) - if partno == 0: - return MessageType24PartA.create(**kwargs) - elif partno == 1: - return MessageType24PartB.create(**kwargs) - else: - raise ValueError(f"Partno {partno} is not allowed!") - - -@attr.s(slots=True) -class MessageType25AddressedStructured(Payload): - msg_type = bit_field(6, int, default=25) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - addressed = bit_field(1, bool, default=0) - structured = bit_field(1, bool, default=0) - - dest_mmsi = bit_field(30, int, default=0) - app_id = bit_field(16, int, default=0) - data = bit_field(82, int, default=0, from_converter=int_to_bytes) - - -@attr.s(slots=True) -class MessageType25BroadcastStructured(Payload): - msg_type = bit_field(6, int, default=25) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - addressed = bit_field(1, bool, default=0) - structured = bit_field(1, bool, default=0) - - app_id = bit_field(16, int, default=0) - data = bit_field(112, int, default=0, from_converter=int_to_bytes) - - -@attr.s(slots=True) -class MessageType25AddressedUnstructured(Payload): - msg_type = bit_field(6, int, default=25) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - addressed = bit_field(1, bool, default=0) - structured = bit_field(1, bool, default=0) - - dest_mmsi = bit_field(30, int, default=0) - data = bit_field(98, int, default=0, from_converter=int_to_bytes) - - -@attr.s(slots=True) -class MessageType25BroadcastUnstructured(Payload): - msg_type = bit_field(6, int, default=25) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - addressed = bit_field(1, bool, default=0) - structured = bit_field(1, bool, default=0) - - data = bit_field(128, int, default=0, from_converter=int_to_bytes) - - -class MessageType25(Payload): - """ - Single Slot Binary Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_25_single_slot_binary_message - - NOTE: This message type is quite uncommon and - I was not able find any real world occurrence of the type. - Also documentation seems to vary. Use with caution. - """ - - @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": - addressed = kwargs.get('addressed', False) - structured = kwargs.get('structured', False) - - if addressed: - if structured: - return MessageType25AddressedStructured.create(**kwargs) - else: - return MessageType25AddressedUnstructured.create(**kwargs) - else: - if structured: - return MessageType25BroadcastStructured.create(**kwargs) - else: - return MessageType25BroadcastUnstructured.create(**kwargs) - - -@attr.s(slots=True) -class MessageType26AddressedStructured(Payload): - msg_type = bit_field(6, int, default=26) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - addressed = bit_field(1, bool, default=0) - structured = bit_field(1, bool, default=0) - - dest_mmsi = bit_field(30, int, default=0) - app_id = bit_field(16, int, default=0) - data = bit_field(958, int, default=0, from_converter=int_to_bytes) - radio = bit_field(20, int, default=0) - - -@attr.s(slots=True) -class MessageType26BroadcastStructured(Payload): - msg_type = bit_field(6, int, default=26) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - addressed = bit_field(1, bool, default=0) - structured = bit_field(1, bool, default=0) - - app_id = bit_field(16, int, default=0) - data = bit_field(988, int, default=0, from_converter=int_to_bytes) - radio = bit_field(20, int, default=0) - - -@attr.s(slots=True) -class MessageType26AddressedUnstructured(Payload): - msg_type = bit_field(6, int, default=26) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - addressed = bit_field(1, bool, default=0) - structured = bit_field(1, bool, default=0) - - dest_mmsi = bit_field(30, int, default=0) - app_id = bit_field(16, int, default=0) - data = bit_field(958, int, default=0, from_converter=int_to_bytes) - radio = bit_field(20, int, default=0) - - -@attr.s(slots=True) -class MessageType26BroadcastUnstructured(Payload): - msg_type = bit_field(6, int, default=26) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - addressed = bit_field(1, bool, default=0) - structured = bit_field(1, bool, default=0) - - data = bit_field(1004, int, default=0, from_converter=int_to_bytes) - radio = bit_field(20, int, default=0) - - -class MessageType26(Payload): - """ - Multiple Slot Binary Message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_26_multiple_slot_binary_message - - NOTE: This message type is quite uncommon and - I was not able find any real world occurrence of the type. - Also documentation seems to vary. Use with caution. - """ - - @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": - addressed = kwargs.get('addressed', False) - structured = kwargs.get('structured', False) - - if addressed: - if structured: - return MessageType26AddressedStructured.create(**kwargs) - else: - return MessageType26BroadcastStructured.create(**kwargs) - else: - if structured: - return MessageType26AddressedUnstructured.create(**kwargs) - else: - return MessageType26BroadcastUnstructured.create(**kwargs) - - -@attr.s(slots=True) -class MessageType27(Payload): - """ - Long Range AIS Broadcast message - Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_27_long_range_ais_broadcast_message - """ - msg_type = bit_field(6, int, default=27) - repeat = bit_field(2, int, default=0) - mmsi = bit_field(30, int) - - accuracy = bit_field(1, int, default=0) - raim = bit_field(1, int, default=0) - status = bit_field(4, int, default=0) - lon = bit_field(18, int, from_converter=lambda v: float(v) * 600.0, default=0) - lat = bit_field(17, int, from_converter=lambda v: float(v) * 600.0, default=0) - speed = bit_field(6, int, default=0) - course = bit_field(9, int, default=0) - gnss = bit_field(1, int, default=0) - spare = bit_field(1, int, default=0) - - ENCODE_MSG = { 0: MessageType1, # there are messages with a zero (0) as an id. these seem to be the same as type 1 messages 1: MessageType1, diff --git a/pyais/messages.py b/pyais/messages.py index 24ebb5d..16a5c0f 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -1,13 +1,15 @@ +import abc import json -from typing import Any, Dict, Optional, Sequence, Tuple, Type, Union +import typing +from typing import Any, Dict, Optional, Sequence, Union +import attr from bitarray import bitarray -from pyais.ais_types import AISType -from pyais.constants import TalkerID -from pyais.decode import decode +from pyais.constants import TalkerID, NavigationStatus, ManeuverIndicator, EpfdType, ShipType from pyais.exceptions import InvalidNMEAMessageException -from pyais.util import decode_into_bit_array, get_int, compute_checksum, deprecated +from pyais.util import decode_into_bit_array, compute_checksum, deprecated, int_to_bin, str_to_bin, \ + encode_ascii_6, from_bytes, int_to_bytes, from_bytes_signed, decode_bin_as_ascii6, get_int def validate_message(msg: bytes) -> None: @@ -105,6 +107,35 @@ def validate_message(msg: bytes) -> None: ) +def bit_field(width: int, d_type: typing.Type[typing.Any], + from_converter: typing.Optional[typing.Callable[[typing.Any], typing.Any]] = None, + to_converter: typing.Optional[typing.Callable[[typing.Any], typing.Any]] = None, + default: typing.Optional[typing.Any] = None, + signed: bool = False, + **kwargs) -> typing.Any: + """ + Simple wrapper around the attr.ib interface to be used in conjunction with the Payload class. + + @param width: The bit-width of the field. + @param d_type: The datatype of the fields value. + @param from_converter: Optional converter function called **before** encoding + @param to_converter: Optional converter function called **after** decoding + @param default: Optional default value to be used when no value is explicitly passed. + @return: An attr.ib field instance. + """ + return attr.ib( + metadata={ + 'width': width, + 'd_type': d_type, + 'from_converter': from_converter, + 'to_converter': to_converter, + 'signed': signed, + 'default': default, + }, + **kwargs + ) + + class NMEAMessage(object): __slots__ = ( 'ais_id', @@ -170,7 +201,7 @@ def __init__(self, raw: bytes) -> None: self.checksum = int(checksum[2:], 16) # Finally decode bytes into bits - self.bit_array: bitarray = decode_into_bit_array(self.payload) + self.bit_array: bitarray = decode_into_bit_array(self.payload, self.fill_bits) self.ais_id: int = get_int(self.bit_array, 0, 6) def __str__(self) -> str: @@ -289,57 +320,915 @@ def data(self) -> bytes: """ return self.payload - def decode(self, silent: bool = True) -> Optional["AISMessage"]: + +@attr.s(slots=True) +class Payload(abc.ABC): + """ + Payload class + -------------- + This class serves as an abstract base class for all messages. + Each message shall inherit from Payload and define it's set of field using the `bit_field` method. + """ + + @classmethod + def fields(cls) -> typing.Tuple[typing.Any]: + """ + A list of all fields that were added to this class using attrs. """ - Decode the message content. + return attr.fields(cls) # type:ignore - @param silent: Boolean. If set to true errors are ignored and None is returned instead + def to_bitarray(self) -> bitarray: """ - msg = AISMessage(self) - try: - msg.decode() - except Exception as e: - if not silent: - raise e + Convert a payload to binary. + """ + out = bitarray() + for field in self.fields(): + width = field.metadata['width'] + d_type = field.metadata['d_type'] + converter = field.metadata['from_converter'] - return msg + val = getattr(self, field.name) + val = converter(val) if converter is not None else val + val = d_type(val) + if d_type == int or d_type == bool: + bits = int_to_bin(val, width) + elif d_type == str: + bits = str_to_bin(val, width) + else: + raise ValueError() + bits = bits[:width] + out += bits -class AISMessage(object): - """ - Initializes a generic AIS message. - """ + return out - def __init__(self, nmea_message: NMEAMessage) -> None: - """Creates an initial empty AIS message""" - self.nmea: NMEAMessage = nmea_message - self.msg_type: AISType = AISType.NOT_IMPLEMENTED - self.content: Dict[str, Any] = {} + def encode(self) -> typing.Tuple[str, int]: + """ + Encode a payload as an ASCII encoded bit vector. The second returned value is the number of fill bits. + """ + bit_arr = self.to_bitarray() + return encode_ascii_6(bit_arr) - def __getitem__(self, item: str) -> Any: - return self.content[item] + @classmethod + def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": + """ + Create a new instance of each Payload class. + @param kwargs: A set of keywords. For each field of `cls` a keyword with the same + name is searched.If no matching keyword argument was provided the + default value will be used - if one is available. + @return: + """ + args = {} - def __str__(self) -> str: - return str(self.content) + # Iterate over each field of the payload class and check for a matching keyword argument. + # If no matching kwarg was provided use a default value + for field in cls.fields(): + key = field.name + try: + args[key] = kwargs[key] + except KeyError: + # Check if a default value was provided + default = field.metadata['default'] + if default is not None: + args[key] = default + return cls(**args) - @property - def fields(self) -> Tuple[Tuple[str, Type[Any]], ...]: - return tuple([(str(key), type(value)) for (key, value) in self.content.items()]) + @classmethod + def from_bitarray(cls, bit_arr: bitarray) -> "MessageType1": + cur = 0 + kwargs = {} - def decode(self) -> None: - """Decodes the given message and extracts it's type and content. - This function potentially fails, if the message is malformed.""" - self.msg_type = AISType(self.nmea.ais_id) - self.content = decode(self.nmea) + # Iterate over the bits until the last bit of the bitarray or all fields are fully decoded + for field in cls.fields(): + width = field.metadata['width'] + d_type = field.metadata['d_type'] + converter = field.metadata['to_converter'] - def asdict(self) -> Dict[str, Any]: - return { - 'nmea': self.nmea.asdict(), - 'decoded': self.content - } + end = min(len(bit_arr), cur + width) + bits = bit_arr[cur: end] + + # Get the correct data type and decoding function + if d_type == int or d_type == bool: + shift = (8 - ((end - cur) % 8)) % 8 + if field.metadata['signed']: + val = from_bytes_signed(bits) >> shift + else: + val = from_bytes(bits) >> shift + val = d_type(val) + elif d_type == str: + val = decode_bin_as_ascii6(bits) + else: + raise ValueError() + + val = converter(val) if converter is not None else val + + kwargs[field.name] = val + + if end >= len(bit_arr): + break + + cur = end + + return cls(**kwargs) + + def asdict(self): + d = {} + for field in self.fields(): + d[field.name] = getattr(self, field.name) + return d def to_json(self) -> str: return json.dumps( self.asdict(), indent=4 ) + + +# +# Conversion functions +# + +def from_speed(v: int) -> NavigationStatus: + return NavigationStatus(float(v) * 10.0) + + +def to_speed(v: int) -> float: + return v / 10.0 + + +def from_lat_lon(v: int) -> float: + return float(v) * 600000.0 + + +def to_lat_lon(v: int) -> float: + return round(float(v) / 600000.0, 6) + + +def from_course(v: int) -> float: + return float(v) * 10.0 + + +def to_course(v: int) -> float: + return v / 10.0 + + +def from_mmsi(v: typing.Union[str, int]): + return int(v) + + +def to_mmsi(v: typing.Union[str, int]): + return str(v).zfill(9) + + + +@attr.s(slots=True) +class MessageType1(Payload): + """ + AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a + """ + msg_type = bit_field(6, int, default=1) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + status = bit_field(4, int, default=0, converter=NavigationStatus) + turn = bit_field(8, int, default=0, signed=True) + speed = bit_field(10, int, from_converter=from_speed, to_converter=to_speed, default=0) + accuracy = bit_field(1, int, default=0) + lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, default=0, signed=True) + lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, default=0, signed=True) + course = bit_field(12, int, from_converter=from_course, to_converter=to_course, default=0) + heading = bit_field(9, int, default=0) + second = bit_field(6, int, default=0) + maneuver = bit_field(2, int, default=0, converter=ManeuverIndicator) + spare = bit_field(3, int, default=0) + raim = bit_field(1, bool, default=0) + radio = bit_field(19, int, default=0) + + +class MessageType2(MessageType1): + """ + AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a + """ + msg_type = bit_field(6, int, default=2) + + +class MessageType3(MessageType1): + """ + AIS Vessel position report using ITDMA (Incremental Time Division Multiple Access) + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a + """ + msg_type = bit_field(6, int, default=3) + + +@attr.s(slots=True) +class MessageType4(Payload): + """ + AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_4_base_station_report + """ + msg_type = bit_field(6, int, default=4) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + year = bit_field(14, int, default=1970) + month = bit_field(4, int, default=1) + day = bit_field(5, int, default=1) + hour = bit_field(5, int, default=0) + minute = bit_field(6, int, default=0) + second = bit_field(6, int, default=0) + accuracy = bit_field(1, int, default=0) + lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) + lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) + epfd = bit_field(4, int, default=0, converter=EpfdType) + spare = bit_field(10, int, default=0) + raim = bit_field(1, int, default=0) + radio = bit_field(19, int, default=0) + + +@attr.s(slots=True) +class MessageType5(Payload): + """ + Static and Voyage Related Data + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_5_static_and_voyage_related_data + """ + msg_type = bit_field(6, int, default=5) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + ais_version = bit_field(2, int, default=0) + imo = bit_field(30, int, default=0) + callsign = bit_field(42, str, default='') + shipname = bit_field(120, str, default='') + shiptype = bit_field(8, int, default=0, converter=ShipType) + to_bow = bit_field(9, int, default=0) + to_stern = bit_field(9, int, default=0) + to_port = bit_field(6, int, default=0) + to_starboard = bit_field(6, int, default=0) + epfd = bit_field(4, int, default=0, converter=EpfdType) + month = bit_field(4, int, default=0) + day = bit_field(5, int, default=0) + hour = bit_field(5, int, default=0) + minute = bit_field(6, int, default=0) + draught = bit_field(8, int, from_converter=from_course, to_converter=to_course, default=0) + destination = bit_field(120, str, default='') + dte = bit_field(1, int, default=0) + spare = bit_field(1, int, default=0) + + +@attr.s(slots=True) +class MessageType6(Payload): + """ + Binary Addresses Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_4_base_station_report + """ + msg_type = bit_field(6, int, default=6) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + seqno = bit_field(2, int, default=0) + dest_mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + retransmit = bit_field(1, bool, default=False) + spare = bit_field(1, int, default=0) + dac = bit_field(10, int, default=0) + fid = bit_field(6, int, default=0) + data = bit_field(920, int, default=0, converter=int_to_bytes) + + +@attr.s(slots=True) +class MessageType7(Payload): + """ + Binary Acknowledge + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_7_binary_acknowledge + """ + msg_type = bit_field(6, int, default=7) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare = bit_field(2, int, default=0) + mmsi1 = bit_field(30, int, default=0) + mmsiseq1 = bit_field(2, int, default=0) + mmsi2 = bit_field(30, int, default=0) + mmsiseq2 = bit_field(2, int, default=0) + mmsi3 = bit_field(30, int, default=0) + mmsiseq3 = bit_field(2, int, default=0) + mmsi4 = bit_field(30, int, default=0) + mmsiseq4 = bit_field(2, int, default=0) + + +@attr.s(slots=True) +class MessageType8(Payload): + """ + Binary Acknowledge + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_8_binary_broadcast_message + """ + msg_type = bit_field(6, int, default=8) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare = bit_field(2, int, default=0) + dac = bit_field(10, int, default=0) + fid = bit_field(6, int, default=0) + data = bit_field(952, int, default=0, from_converter=int_to_bytes) + + +@attr.s(slots=True) +class MessageType9(Payload): + """ + Standard SAR Aircraft Position Report + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_9_standard_sar_aircraft_position_report + """ + msg_type = bit_field(6, int, default=9) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + alt = bit_field(12, int, default=0) + speed = bit_field(10, int, default=0) + accuracy = bit_field(1, int, default=0) + lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) + lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) + course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) + second = bit_field(6, int, default=0) + reserved = bit_field(8, int, default=0) + dte = bit_field(1, int, default=0) + spare = bit_field(3, int, default=0) + assigned = bit_field(1, int, default=0) + raim = bit_field(1, int, default=0) + radio = bit_field(20, int, default=0) + + +@attr.s(slots=True) +class MessageType10(Payload): + """ + UTC/Date Inquiry + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_10_utc_date_inquiry + """ + msg_type = bit_field(6, int, default=10) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare_1 = bit_field(2, int, default=0) + dest_mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare_2 = bit_field(2, int, default=0) + + +class MessageType11(MessageType4): + """ + UTC/Date Response + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_11_utc_date_response + """ + msg_type = bit_field(6, int, default=11) + + +@attr.s(slots=True) +class MessageType12(Payload): + """ + Addressed Safety-Related Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_12_addressed_safety_related_message + """ + msg_type = bit_field(6, int, default=12) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + seqno = bit_field(2, int, default=0) + dest_mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + retransmit = bit_field(1, int, default=0) + spare = bit_field(1, int, default=0) + text = bit_field(936, str, default='') + + +class MessageType13(MessageType7): + """ + Identical to type 7 + """ + msg_type = bit_field(6, int, default=13) + + +@attr.s(slots=True) +class MessageType14(Payload): + """ + Safety-Related Broadcast Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_14_safety_related_broadcast_message + """ + msg_type = bit_field(6, int, default=14) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare = bit_field(2, int, default=0) + text = bit_field(968, str, default='') + + +@attr.s(slots=True) +class MessageType15(Payload): + """ + Interrogation + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_15_interrogation + """ + msg_type = bit_field(6, int, default=15) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare_1 = bit_field(2, int, default=0) + mmsi1 = bit_field(30, int, default=0) + type1_1 = bit_field(6, int, default=0) + offset1_1 = bit_field(12, int, default=0) + spare_2 = bit_field(2, int, default=0) + type1_2 = bit_field(6, int, default=0) + offset1_2 = bit_field(12, int, default=0) + spare_3 = bit_field(2, int, default=0) + mmsi2 = bit_field(30, int, default=0) + type2_1 = bit_field(6, int, default=0) + offset2_1 = bit_field(12, int, default=0) + spare_4 = bit_field(2, int, default=0) + + +@attr.s(slots=True) +class MessageType16(Payload): + """ + Assignment Mode Command + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_16_assignment_mode_command + """ + msg_type = bit_field(6, int, default=16) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare = bit_field(2, int, default=0) + + mmsi1 = bit_field(30, int, default=0) + offset1 = bit_field(12, int, default=0) + increment1 = bit_field(10, int, default=0) + + mmsi2 = bit_field(30, int, default=0) + offset2 = bit_field(12, int, default=0) + increment2 = bit_field(10, int, default=0) + + +@attr.s(slots=True) +class MessageType17(Payload): + """ + DGNSS Broadcast Binary Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_17_dgnss_broadcast_binary_message + """ + msg_type = bit_field(6, int, default=17) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare_1 = bit_field(2, int, default=0) + lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) + lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) + spare_2 = bit_field(5, int, default=0) + data = bit_field(736, int, default=0, from_converter=int_to_bytes) + + +@attr.s(slots=True) +class MessageType18(Payload): + """ + Standard Class B CS Position Report + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_18_standard_class_b_cs_position_report + """ + msg_type = bit_field(6, int, default=18) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + reserved = bit_field(8, int, default=0) + speed = bit_field(10, int, default=0) + accuracy = bit_field(1, int, default=0) + lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) + lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) + course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) + heading = bit_field(9, int, default=0) + second = bit_field(6, int, default=0) + reserved_2 = bit_field(2, int, default=0) + cs = bit_field(1, bool, default=0) + display = bit_field(1, bool, default=0) + dsc = bit_field(1, bool, default=0) + band = bit_field(1, bool, default=0) + msg22 = bit_field(1, bool, default=0) + assigned = bit_field(1, bool, default=0) + raim = bit_field(1, bool, default=0) + radio = bit_field(20, int, default=0) + + +@attr.s(slots=True) +class MessageType19(Payload): + """ + Extended Class B CS Position Report + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_19_extended_class_b_cs_position_report + """ + msg_type = bit_field(6, int, default=19) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + reserved = bit_field(8, int, default=0) + speed = bit_field(10, int, from_converter=lambda v: float(v) * 10.0, default=0) + accuracy = bit_field(1, int, default=0) + lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) + lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) + course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) + heading = bit_field(9, int, default=0) + second = bit_field(6, int, default=0) + regional = bit_field(4, int, default=0) + shipname = bit_field(120, str, default='') + shiptype = bit_field(8, int, default=0) + to_bow = bit_field(9, int, default=0) + to_stern = bit_field(9, int, default=0) + to_port = bit_field(6, int, default=0) + to_starboard = bit_field(6, int, default=0) + epfd = bit_field(4, int, default=0) + raim = bit_field(1, bool, default=0) + dte = bit_field(1, bool, default=0) + assigned = bit_field(1, int, default=0) + spare = bit_field(4, int, default=0) + + +@attr.s(slots=True) +class MessageType20(Payload): + """ + Data Link Management Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_20_data_link_management_message + """ + msg_type = bit_field(6, int, default=20) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare = bit_field(2, int, default=0) + + offset1 = bit_field(12, int, default=0) + number1 = bit_field(4, int, default=0) + timeout1 = bit_field(3, int, default=0) + increment1 = bit_field(11, int, default=0) + + offset2 = bit_field(12, int, default=0) + number2 = bit_field(4, int, default=0) + timeout2 = bit_field(3, int, default=0) + increment2 = bit_field(11, int, default=0) + + offset3 = bit_field(12, int, default=0) + number3 = bit_field(4, int, default=0) + timeout3 = bit_field(3, int, default=0) + increment3 = bit_field(11, int, default=0) + + offset4 = bit_field(12, int, default=0) + number4 = bit_field(4, int, default=0) + timeout4 = bit_field(3, int, default=0) + increment4 = bit_field(11, int, default=0) + + +@attr.s(slots=True) +class MessageType21(Payload): + """ + Aid-to-Navigation Report + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_21_aid_to_navigation_report + """ + msg_type = bit_field(6, int, default=21) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + aid_type = bit_field(5, int, default=0) + shipname = bit_field(120, str, default='') + accuracy = bit_field(1, bool, default=0) + lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) + lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) + to_bow = bit_field(9, int, default=0) + to_stern = bit_field(9, int, default=0) + to_port = bit_field(6, int, default=0) + to_starboard = bit_field(6, int, default=0) + epfd = bit_field(4, int, default=0) + second = bit_field(6, int, default=0) + off_position = bit_field(1, bool, default=0) + regional = bit_field(8, int, default=0) + raim = bit_field(1, bool, default=0) + virtual_aid = bit_field(1, bool, default=0) + assigned = bit_field(1, bool, default=0) + spare = bit_field(1, int, default=0) + name_ext = bit_field(88, str, default='') + + +@attr.s(slots=True) +class MessageType22Addressed(Payload): + """ + Channel Management + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_22_channel_management + """ + msg_type = bit_field(6, int, default=22) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare_1 = bit_field(2, int, default=0) # 40 bits + + channel_a = bit_field(12, int, default=0) + channel_b = bit_field(12, int, default=0) + txrx = bit_field(4, int, default=0) + power = bit_field(1, bool, default=0) # 69 bits + + # If it is addressed (addressed field is 1), + # the same span of data is interpreted as two 30-bit MMSIs + # beginning at bit offsets 69 and 104 respectively. + dest1 = bit_field(30, int, default=0) + empty_1 = bit_field(5, int, default=0) + dest2 = bit_field(30, int, default=0) + empty_2 = bit_field(5, int, default=0) + + addressed = bit_field(1, bool, default=0) + band_a = bit_field(1, bool, default=0) + band_b = bit_field(1, bool, default=0) + zonesize = bit_field(3, int, default=0) + spare_2 = bit_field(23, int, default=0) + + +@attr.s(slots=True) +class MessageType22Broadcast(Payload): + """ + Channel Management + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_22_channel_management + """ + msg_type = bit_field(6, int, default=22) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare_1 = bit_field(2, int, default=0) + + channel_a = bit_field(12, int, default=0) + channel_b = bit_field(12, int, default=0) + txrx = bit_field(4, int, default=0) + power = bit_field(1, bool, default=0) + + # If the message is broadcast (addressed field is 0), + # the ne_lon, ne_lat, sw_lon, and sw_lat fields are the + # corners of a rectangular jurisdiction area over which control parameter + ne_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) + ne_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) + sw_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) + sw_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) + + addressed = bit_field(1, bool, default=0) + band_a = bit_field(1, bool, default=0) + band_b = bit_field(1, bool, default=0) + zonesize = bit_field(3, int, default=0) + spare_2 = bit_field(23, int, default=0) + + +class MessageType22(Payload): + """ + Type 22 messages are different from other messages: + The encoding differs depending on the `addressed` field. If the message is broadcast + (addressed field is 0), the ne_lon, ne_lat, sw_lon, and sw_lat fields are the + corners of a rectangular jurisdiction area over which control parameters are to + be set. If it is addressed (addressed field is 1), + the same span of data is interpreted as two 30-bit MMSIs beginning + at bit offsets 69 and 104 respectively. + """ + + @classmethod + def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": + if kwargs.get('addressed', False): + return MessageType22Addressed.create(**kwargs) + else: + return MessageType22Broadcast.create(**kwargs) + + +@attr.s(slots=True) +class MessageType23(Payload): + """ + Group Assignment Command + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_23_group_assignment_command + """ + msg_type = bit_field(6, int, default=23) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + spare_1 = bit_field(2, int, default=0) + + ne_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) + ne_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) + sw_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) + sw_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) + + station_type = bit_field(4, int, default=0) + ship_type = bit_field(8, int, default=0) + spare_2 = bit_field(22, int, default=0) + + txrx = bit_field(2, int, default=0) + interval = bit_field(4, int, default=0) + quiet = bit_field(4, int, default=0) + spare_3 = bit_field(6, int, default=0) + + +@attr.s(slots=True) +class MessageType24PartA(Payload): + msg_type = bit_field(6, int, default=24) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + partno = bit_field(2, int, default=0) + shipname = bit_field(120, str, default='') + spare_1 = bit_field(8, int, default=0) + + +@attr.s(slots=True) +class MessageType24PartB(Payload): + msg_type = bit_field(6, int, default=24) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + partno = bit_field(2, int, default=0) + shiptype = bit_field(8, int, default=0) + vendorid = bit_field(18, str, default=0) + model = bit_field(4, int, default=0) + serial = bit_field(20, int, default=0) + callsign = bit_field(42, str, default='') + + to_bow = bit_field(9, int, default=0) + to_stern = bit_field(9, int, default=0) + to_port = bit_field(6, int, default=0) + to_starboard = bit_field(6, int, default=0) + + spare_2 = bit_field(6, int, default=0) + + +class MessageType24(Payload): + """ + Static Data Report + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_24_static_data_report + + Just like message type 22, this message encodes different fields depending + on the `partno` field. + If the Part Number field is 0, the rest of the message is interpreted as a Part A; if it is 1, + the rest of the message is interpreted as a Part B; + """ + + @classmethod + def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": + partno: int = int(kwargs.get('partno', 0)) + if partno == 0: + return MessageType24PartA.create(**kwargs) + elif partno == 1: + return MessageType24PartB.create(**kwargs) + else: + raise ValueError(f"Partno {partno} is not allowed!") + + +@attr.s(slots=True) +class MessageType25AddressedStructured(Payload): + msg_type = bit_field(6, int, default=25) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + addressed = bit_field(1, bool, default=0) + structured = bit_field(1, bool, default=0) + + dest_mmsi = bit_field(30, int, default=0) + app_id = bit_field(16, int, default=0) + data = bit_field(82, int, default=0, from_converter=int_to_bytes) + + +@attr.s(slots=True) +class MessageType25BroadcastStructured(Payload): + msg_type = bit_field(6, int, default=25) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + addressed = bit_field(1, bool, default=0) + structured = bit_field(1, bool, default=0) + + app_id = bit_field(16, int, default=0) + data = bit_field(112, int, default=0, from_converter=int_to_bytes) + + +@attr.s(slots=True) +class MessageType25AddressedUnstructured(Payload): + msg_type = bit_field(6, int, default=25) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + addressed = bit_field(1, bool, default=0) + structured = bit_field(1, bool, default=0) + + dest_mmsi = bit_field(30, int, default=0) + data = bit_field(98, int, default=0, from_converter=int_to_bytes) + + +@attr.s(slots=True) +class MessageType25BroadcastUnstructured(Payload): + msg_type = bit_field(6, int, default=25) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + addressed = bit_field(1, bool, default=0) + structured = bit_field(1, bool, default=0) + + data = bit_field(128, int, default=0, from_converter=int_to_bytes) + + +class MessageType25(Payload): + """ + Single Slot Binary Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_25_single_slot_binary_message + + NOTE: This message type is quite uncommon and + I was not able find any real world occurrence of the type. + Also documentation seems to vary. Use with caution. + """ + + @classmethod + def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": + addressed = kwargs.get('addressed', False) + structured = kwargs.get('structured', False) + + if addressed: + if structured: + return MessageType25AddressedStructured.create(**kwargs) + else: + return MessageType25AddressedUnstructured.create(**kwargs) + else: + if structured: + return MessageType25BroadcastStructured.create(**kwargs) + else: + return MessageType25BroadcastUnstructured.create(**kwargs) + + +@attr.s(slots=True) +class MessageType26AddressedStructured(Payload): + msg_type = bit_field(6, int, default=26) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + addressed = bit_field(1, bool, default=0) + structured = bit_field(1, bool, default=0) + + dest_mmsi = bit_field(30, int, default=0) + app_id = bit_field(16, int, default=0) + data = bit_field(958, int, default=0, from_converter=int_to_bytes) + radio = bit_field(20, int, default=0) + + +@attr.s(slots=True) +class MessageType26BroadcastStructured(Payload): + msg_type = bit_field(6, int, default=26) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + addressed = bit_field(1, bool, default=0) + structured = bit_field(1, bool, default=0) + + app_id = bit_field(16, int, default=0) + data = bit_field(988, int, default=0, from_converter=int_to_bytes) + radio = bit_field(20, int, default=0) + + +@attr.s(slots=True) +class MessageType26AddressedUnstructured(Payload): + msg_type = bit_field(6, int, default=26) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + addressed = bit_field(1, bool, default=0) + structured = bit_field(1, bool, default=0) + + dest_mmsi = bit_field(30, int, default=0) + app_id = bit_field(16, int, default=0) + data = bit_field(958, int, default=0, from_converter=int_to_bytes) + radio = bit_field(20, int, default=0) + + +@attr.s(slots=True) +class MessageType26BroadcastUnstructured(Payload): + msg_type = bit_field(6, int, default=26) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + addressed = bit_field(1, bool, default=0) + structured = bit_field(1, bool, default=0) + + data = bit_field(1004, int, default=0, from_converter=int_to_bytes) + radio = bit_field(20, int, default=0) + + +class MessageType26(Payload): + """ + Multiple Slot Binary Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_26_multiple_slot_binary_message + + NOTE: This message type is quite uncommon and + I was not able find any real world occurrence of the type. + Also documentation seems to vary. Use with caution. + """ + + @classmethod + def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": + addressed = kwargs.get('addressed', False) + structured = kwargs.get('structured', False) + + if addressed: + if structured: + return MessageType26AddressedStructured.create(**kwargs) + else: + return MessageType26BroadcastStructured.create(**kwargs) + else: + if structured: + return MessageType26AddressedUnstructured.create(**kwargs) + else: + return MessageType26BroadcastUnstructured.create(**kwargs) + + +@attr.s(slots=True) +class MessageType27(Payload): + """ + Long Range AIS Broadcast message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_27_long_range_ais_broadcast_message + """ + msg_type = bit_field(6, int, default=27) + repeat = bit_field(2, int, default=0) + mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) + + accuracy = bit_field(1, int, default=0) + raim = bit_field(1, int, default=0) + status = bit_field(4, int, default=0) + lon = bit_field(18, int, from_converter=lambda v: float(v) * 600.0, default=0) + lat = bit_field(17, int, from_converter=lambda v: float(v) * 600.0, default=0) + speed = bit_field(6, int, default=0) + course = bit_field(9, int, default=0) + gnss = bit_field(1, int, default=0) + spare = bit_field(1, int, default=0) diff --git a/pyais/util.py b/pyais/util.py index e43b58b..5494573 100644 --- a/pyais/util.py +++ b/pyais/util.py @@ -31,22 +31,29 @@ def wrapper(self: Any) -> Any: return wrapper -def decode_into_bit_array(data: bytes) -> bitarray: +def decode_into_bit_array(data: bytes, fill_bits: int = 0) -> bitarray: """ Decodes a raw AIS message into a bitarray. - :param data: Raw AIS message in bytes + :param data: Raw AIS message in bytes + :param fill_bits: Number of trailing fill bits to be ignored :return: """ bit_arr = bitarray() - - for _, c in enumerate(data): + length = len(data) + for i, c in enumerate(data): if c < 0x30 or c > 0x77 or 0x57 < c < 0x6: raise ValueError(f"Invalid character: {chr(c)}") # Convert 8 bit binary to 6 bit binary c -= 0x30 if (c < 0x60) else 0x38 c &= 0x3F - bit_arr += bitarray(f'{c:06b}') + + if i == length - 1 and fill_bits: + # The last part be shorter than 6 bits and contain fill bits + c = c >> fill_bits + bit_arr += bitarray(f'{c:b}'.zfill(6 - fill_bits)) + else: + bit_arr += bitarray(f'{c:06b}') return bit_arr @@ -100,16 +107,6 @@ def get_int(data: bitarray, ix_low: int, ix_high: int, signed: bool = False) -> return i >> shift -def get_mmsi(data: bitarray, ix_low: int, ix_high: int) -> str: - """ - A Maritime Mobile Service Identity (MMSI) is a series of nine digits. - Every digit is required and therefore we can NOT use a int. - See: issue #6 - """ - mmsi_int: int = get_int(data, ix_low, ix_high) - return str(mmsi_int).zfill(9) - - def compute_checksum(msg: Union[str, bytes]) -> int: """ Compute the checksum of a given message. @@ -152,3 +149,123 @@ def _pop_oldest(self) -> Any: def _items_to_pop(self) -> int: # delete 1/5th of keys return self.maxlen // 5 + + +# https://gpsd.gitlab.io/gpsd/AIVDM.html#_aivdmaivdo_payload_armoring +PAYLOAD_ARMOR = { + 0: '0', 1: '1', 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: ':', + 11: ';', 12: '<', 13: '=', 14: '>', 15: '?', 16: '@', 17: 'A', 18: 'B', 19: 'C', 20: 'D', + 21: 'E', 22: 'F', 23: 'G', 24: 'H', 25: 'I', 26: 'J', 27: 'K', 28: 'L', 29: 'M', 30: 'N', + 31: 'O', 32: 'P', 33: 'Q', 34: 'R', 35: 'S', 36: 'T', 37: 'U', 38: 'V', 39: 'W', 40: '`', + 41: 'a', 42: 'b', 43: 'c', 44: 'd', 45: 'e', 46: 'f', 47: 'g', 48: 'h', 49: 'i', 50: 'j', + 51: 'k', 52: 'l', 53: 'm', 54: 'n', 55: 'o', 56: 'p', 57: 'q', 58: 'r', 59: 's', 60: 't', + 61: 'u', 62: 'v', 63: 'w' +} + +# https://gpsd.gitlab.io/gpsd/AIVDM.html#_ais_payload_data_types +SIX_BIT_ENCODING = { + '@': 0, 'A': 1, 'B': 2, 'C': 3, 'D': 4, 'E': 5, 'F': 6, 'G': 7, 'H': 8, 'I': 9, 'J': 10, + 'K': 11, 'L': 12, 'M': 13, 'N': 14, 'O': 15, 'P': 16, 'Q': 17, 'R': 18, 'S': 19, 'T': 20, + 'U': 21, 'V': 22, 'W': 23, 'X': 24, 'Y': 25, 'Z': 26, '[': 27, '\\': 28, ']': 29, '^': 30, + '_': 31, ' ': 32, '!': 33, '"': 34, '#': 35, '$': 36, '%': 37, '&': 38, '\'': 39, '(': 40, + ')': 41, '*': 42, '+': 43, ',': 44, '-': 45, '.': 46, '/': 47, '0': 48, '1': 49, '2': 50, + '3': 51, '4': 52, '5': 53, '6': 54, '7': 55, '8': 56, '9': 57, ':': 58, ';': 59, '<': 60, + '=': 61, '>': 62, '?': 63 +} + + +def to_six_bit(char: str) -> str: + """ + Encode a single character as six-bit bitstring. + @param char: The character to encode + @return: The six-bit representation as string + """ + char = char.upper() + try: + encoding = SIX_BIT_ENCODING[char] + return f"{encoding:06b}" + except KeyError: + raise ValueError(f"received char '{char}' that cant be encoded") + + +def encode_ascii_6(bits: bitarray) -> typing.Tuple[str, int]: + """ + Transform the bitarray to an ASCII-encoded bit vector. + Each character represents six bits of data. + @param bits: The bitarray to convert to an ASCII-encoded bit vector. + @return: ASCII-encoded bit vector and the number of fill bits required to pad the data payload to a 6 bit boundary. + """ + out = "" + chunk: bitarray + padding = 0 + for chunk in chunks(bits, 6): # type:ignore + padding = 6 - len(chunk) + num = from_bytes(chunk.tobytes()) >> 2 + if padding: + num = num >> padding + armor = PAYLOAD_ARMOR[num] + out += armor + return out, padding + + +def int_to_bytes(val: typing.Union[int, bytes]) -> int: + """ + Convert a bytes object to an integer. Byteorder is big. + + @param val: A bytes object to convert to an int. If the value is already an int, this is a NO-OP. + @return: Integer representation of `val` + """ + if isinstance(val, int): + return val + return int.from_bytes(val, 'big') + + +def int_to_bin(val: typing.Union[int, bool], width: int) -> bitarray: + """ + Convert an integer or boolean value to binary. If the value is too great to fit into + `width` bits, the maximum possible number that still fits is used. + + @param val: Any integer or boolean value. + @param width: The bit width. If less than width bits are required, leading zeros are added. + @return: The binary representation of value with exactly width bits. Type is bitarray. + """ + # Compute the total number of bytes required to hold up to `width` bits. + n_bytes, mod = divmod(width, 8) + if mod > 0: + n_bytes += 1 + + # If the value is too big, return a bitarray of all 1's + mask = (1 << width) - 1 + if val >= mask: + return bitarray('1' * width) + + bits = bitarray(endian='big') + bits.frombytes(val.to_bytes(n_bytes, 'big', signed=True)) + return bits[8 - mod if mod else 0:] + + +def str_to_bin(val: str, width: int) -> bitarray: + """ + Convert a string value to binary using six-bit ASCII encoding up to `width` chars. + + @param val: The string to first convert to six-bit ASCII and then to binary. + @param width: The width of the full string. If the string has fewer characters than width, trailing '@' are added. + @return: The binary representation of value with exactly width bits. Type is bitarray. + """ + out = bitarray(endian='big') + + # Each char will be converted to a six-bit binary vector. + # Therefore, the total number of chars is floor(WIDTH / 6). + num_chars = int(width / 6) + + # Add trailing '@' if the string is shorter than `width` + for _ in range(num_chars - len(val)): + val += "@" + + # Encode AT MOST width characters + for char in val[:num_chars]: + # Covert each char to six-bit ASCII vector + txt = to_six_bit(char) + out += bitarray(txt) + + return out diff --git a/tests/test_ais.py b/tests/test_ais.py index 940a09a..fd9afca 100644 --- a/tests/test_ais.py +++ b/tests/test_ais.py @@ -1,10 +1,11 @@ +import textwrap import unittest -from pyais import decode_msg +from pyais import NMEAMessage from pyais.ais_types import AISType from pyais.constants import ManeuverIndicator, NavigationStatus, ShipType, NavAid, EpfdType +from pyais.decode import decode from pyais.exceptions import UnknownMessageException -from pyais.messages import AISMessage, NMEAMessage from pyais.stream import ByteStream @@ -23,50 +24,29 @@ class TestAIS(unittest.TestCase): The latter sometimes is a bit weird and therefore I used aislib to verify my results. """ - def test_nmea(self): - """ - Test if ais message still contains the original nmea message - """ - nmea = NMEAMessage(b"!AIVDM,1,1,,B,91b55wi;hbOS@OdQAC062Ch2089h,0*30") - assert nmea.decode().nmea == nmea - def test_to_json(self): - json_dump = NMEAMessage(b"!AIVDM,1,1,,A,15NPOOPP00o?b=bE`UNv4?w428D;,0*24").decode().to_json() - text = """{ - "nmea": { - "ais_id": 1, - "raw": "!AIVDM,1,1,,A,15NPOOPP00o?b=bE`UNv4?w428D;,0*24", - "talker": "AI", - "type": "VDM", - "message_fragments": 1, - "fragment_number": 1, - "message_id": null, - "channel": "A", - "payload": "15NPOOPP00o?b=bE`UNv4?w428D;", - "fill_bits": 0, - "checksum": 36, - "bit_array": "000001000101011110100000011111011111100000100000000000000000110111001111101010001101101010010101101000100101011110111110000100001111111111000100000010001000010100001011" - }, - "decoded": { - "type": 1, - "repeat": 0, - "mmsi": "367533950", - "status": 0, - "turn": -128, - "speed": 0.0, - "accuracy": 1, - "lon": -122.40823166666667, - "lat": 37.808418333333336, - "course": 360.0, - "heading": 511, - "second": 34, - "maneuver": 0, - "raim": 1, - "radio": 34059 - } -}""" + json_dump = decode(b"!AIVDM,1,1,,A,15NPOOPP00o?b=bE`UNv4?w428D;,0*24").to_json() + text = textwrap.dedent("""{ + "msg_type": 1, + "repeat": 0, + "mmsi": 367533950, + "status": 0, + "turn": 0, + "speed": 0.0, + "accuracy": 1, + "lon": 324.984195, + "lat": 37.808418, + "course": 360.0, + "heading": 511, + "second": 34, + "maneuver": 0, + "spare": 0, + "raim": 1, + "radio": 34059 +}""") self.assertEqual(json_dump, text) + @unittest.skip("TODO") def test_msg_type(self): """ Test if msg type is correct @@ -83,27 +63,31 @@ def test_msg_type(self): ]) assert nmea.decode().msg_type == AISType.STATIC_AND_VOYAGE - def test_msg_getitem(self): - """ - Test if one can get items - """ - msg = NMEAMessage(b"!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05").decode() - assert msg['repeat'] == 0 - - def test_msg_type_1(self): - msg = NMEAMessage(b"!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C").decode() - assert msg.content == {'type': 1, 'repeat': 0, 'mmsi': "366053209", - 'status': NavigationStatus.RestrictedManoeuverability, 'turn': 0, - 'speed': 0, - 'accuracy': 0, - 'lon': -122.34161833333333, 'lat': 37.80211833333333, 'course': 219.3, - 'heading': 1, - 'second': 59, 'maneuver': ManeuverIndicator.NotAvailable, 'raim': False, - 'radio': 2281} - - msg = NMEAMessage(b"!AIVDM,1,1,,A,15NPOOPP00o?b=bE`UNv4?w428D;,0*24").decode() - assert msg['type'] == 1 - assert msg['mmsi'] == "367533950" + def test_msg_type_1_a(self): + result = decode(b"!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C").asdict() + assert result == { + 'msg_type': 1, + 'repeat': 0, + 'mmsi': '366053209', + 'status': NavigationStatus.RestrictedManoeuverability, + 'turn': 0, + 'speed': 0.0, + 'accuracy': 0, + 'lon': -122.341618, + 'lat': 37.802118, + 'course': 219.3, + 'heading': 1, + 'second': 59, + 'maneuver': ManeuverIndicator.NotAvailable, + 'raim': False, + 'radio': 2281, + 'spare': 0 + } + + def test_msg_type_1_b(self): + msg = decode(b"!AIVDM,1,1,,A,15NPOOPP00o?b=bE`UNv4?w428D;,0*24").asdict() + assert msg['msg_type'] == 1 + assert msg['mmsi'] == '367533950' assert msg['repeat'] == 0 assert msg['status'] == NavigationStatus.UnderWayUsingEngine assert msg['turn'] == -128 @@ -117,18 +101,15 @@ def test_msg_type_1(self): assert msg['maneuver'] == ManeuverIndicator.NotAvailable assert msg['raim'] - msg = NMEAMessage(b"!AIVDM,1,1,,B,181:Kjh01ewHFRPDK1s3IRcn06sd,0*08").decode() + def test_msg_type_1_c(self): + msg = decode(b"!AIVDM,1,1,,B,181:Kjh01ewHFRPDK1s3IRcn06sd,0*08").asdict() assert msg['course'] == 87.0 - assert msg['mmsi'] == "538090443" + assert msg['mmsi'] == '538090443' assert msg['speed'] == 10.9 def test_decode_pos_1_2_3(self): # weired message of type 0 as part of issue #4 - msg: NMEAMessage = NMEAMessage(b"!AIVDM,1,1,,B,0S9edj0P03PecbBN`ja@0?w42cFC,0*7C") - - assert msg.is_valid - content: AISMessage = msg.decode(silent=False) - assert msg + content = decode(b"!AIVDM,1,1,,B,0S9edj0P03PecbBN`ja@0?w42cFC,0*7C").asdict() assert content['repeat'] == 2 assert content['mmsi'] == "211512520" @@ -138,12 +119,11 @@ def test_decode_pos_1_2_3(self): assert round(content['lon'], 4) == 9.9794 assert round(content['course'], 1) == 0.0 - msg: NMEAMessage = NMEAMessage(b"!AIVDM,1,1,,B,0S9edj0P03PecbBN`ja@0?w42cFC,0*7C") - assert msg.decode().to_json() + assert decode(b"!AIVDM,1,1,,B,0S9edj0P03PecbBN`ja@0?w42cFC,0*7C").to_json() def test_msg_type_3(self): - msg = NMEAMessage(b"!AIVDM,1,1,,A,35NSH95001G?wopE`beasVk@0E5:,0*6F").decode() - assert msg['type'] == 3 + msg = decode(b"!AIVDM,1,1,,A,35NSH95001G?wopE`beasVk@0E5:,0*6F").asdict() + assert msg['msg_type'] == 3 assert msg['mmsi'] == "367581220" assert msg['repeat'] == 0 assert msg['status'] == NavigationStatus.Moored @@ -158,10 +138,10 @@ def test_msg_type_3(self): assert msg['maneuver'] == ManeuverIndicator.NotAvailable assert not msg['raim'] - def test_msg_type_4(self): - msg = NMEAMessage(b"!AIVDM,1,1,,A,403OviQuMGCqWrRO9>E6fE700@GO,0*4D").decode() - assert round(msg['lon'], 4) == -76.3524 - assert round(msg['lat'], 4) == 36.8838 + def test_msg_type_4_a(self): + msg = decode(b"!AIVDM,1,1,,A,403OviQuMGCqWrRO9>E6fE700@GO,0*4D").asdict() + assert msg['lon'] == -76.352362 + assert msg['lat'] == 36.883767 assert msg['accuracy'] == 1 assert msg['year'] == 2007 assert msg['month'] == 5 @@ -169,7 +149,8 @@ def test_msg_type_4(self): assert msg['minute'] == 57 assert msg['second'] == 39 - msg = NMEAMessage(b"!AIVDM,1,1,,B,403OtVAv>lba;o?Ia`E`4G?02H6k,0*44").decode() + def test_msg_type_4_b(self): + msg = decode(b"!AIVDM,1,1,,B,403OtVAv>lba;o?Ia`E`4G?02H6k,0*44").asdict() assert round(msg['lon'], 4) == -122.4648 assert round(msg['lat'], 4) == 37.7943 assert msg['mmsi'] == "003669145" @@ -180,12 +161,14 @@ def test_msg_type_4(self): assert msg['hour'] == 10 assert msg['minute'] == 41 assert msg['second'] == 11 + assert msg['epfd'].value == 0 + assert msg['epfd'] == EpfdType.Undefined def test_msg_type_5(self): - msg = NMEAMessage.assemble_from_iterable(messages=[ - NMEAMessage(b"!AIVDM,2,1,1,A,55?MbV02;H;s Date: Sat, 29 Jan 2022 16:09:22 +0100 Subject: [PATCH 04/18] All messages sort of work --- README.md | 4 +- examples/encode.py | 4 +- pyais/__init__.py | 4 +- pyais/encode.py | 4 +- pyais/messages.py | 155 +++++++++++++------ tests/test_ais.py | 348 ++++++++++++++++++++++--------------------- tests/test_encode.py | 24 +-- 7 files changed, 306 insertions(+), 237 deletions(-) diff --git a/README.md b/README.md index a02ffe7..ae7c747 100644 --- a/README.md +++ b/README.md @@ -139,10 +139,10 @@ encoded = encode_dict(data, radio_channel="B", talker_id="AIVDM")[0] It is also possible to create messages directly and pass them to `encode_payload`. ```py -from pyais.encode import MessageType5, encode_payload +from pyais.encode import MessageType5, encode_msg payload = MessageType5.create(mmsi="123", shipname="Titanic", callsign="TITANIC", destination="New York") -encoded = encode_payload(payload) +encoded = encode_msg(payload) print(encoded) ``` diff --git a/examples/encode.py b/examples/encode.py index d25690c..4d4695f 100644 --- a/examples/encode.py +++ b/examples/encode.py @@ -1,4 +1,4 @@ -from pyais.encode import MessageType5, encode_payload +from pyais.encode import MessageType5, encode_msg from pyais.encode import encode_dict @@ -13,5 +13,5 @@ # It is also possible to create messages directly and pass them to `encode_payload` payload = MessageType5.create(mmsi="123", shipname="Titanic", callsign="TITANIC", destination="New York") -encoded = encode_payload(payload) +encoded = encode_msg(payload) print(encoded) diff --git a/pyais/__init__.py b/pyais/__init__.py index 8964db9..6756f3a 100644 --- a/pyais/__init__.py +++ b/pyais/__init__.py @@ -1,13 +1,13 @@ from pyais.messages import NMEAMessage from pyais.stream import TCPStream, FileReaderStream, IterMessages -from pyais.encode import encode_dict, encode_payload +from pyais.encode import encode_dict, encode_msg __license__ = 'MIT' __version__ = '1.7.0' __all__ = ( 'encode_dict', - 'encode_payload', + 'encode_msg', 'NMEAMessage', 'TCPStream', 'IterMessages', diff --git a/pyais/encode.py b/pyais/encode.py index 1739f7b..2ec6982 100644 --- a/pyais/encode.py +++ b/pyais/encode.py @@ -133,12 +133,12 @@ def encode_dict(data: DATA_DICT, talker_id: str = "AIVDO", radio_channel: str = return ais_to_nmea_0183(armored_payload, talker_id, radio_channel, fill_bits) -def encode_payload(payload: Payload, talker_id: str = "AIVDO", radio_channel: str = "A") -> AIS_SENTENCES: +def encode_msg(msg: Payload, talker_id: str = "AIVDO", radio_channel: str = "A") -> AIS_SENTENCES: if talker_id not in ("AIVDM", "AIVDO"): raise ValueError("talker_id must be any of ['AIVDM', 'AIVDO']") if radio_channel not in ('A', 'B'): raise ValueError("radio_channel must be any of ['A', 'B']") - armored_payload, fill_bits = payload.encode() + armored_payload, fill_bits = msg.encode() return ais_to_nmea_0183(armored_payload, talker_id, radio_channel, fill_bits) diff --git a/pyais/messages.py b/pyais/messages.py index 16a5c0f..bf154ad 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -6,7 +6,8 @@ import attr from bitarray import bitarray -from pyais.constants import TalkerID, NavigationStatus, ManeuverIndicator, EpfdType, ShipType +from pyais.constants import TalkerID, NavigationStatus, ManeuverIndicator, EpfdType, ShipType, NavAid, StationType, \ + TransmitMode, StationIntervals from pyais.exceptions import InvalidNMEAMessageException from pyais.util import decode_into_bit_array, compute_checksum, deprecated, int_to_bin, str_to_bin, \ encode_ascii_6, from_bytes, int_to_bytes, from_bytes_signed, decode_bin_as_ascii6, get_int @@ -394,12 +395,17 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payloa return cls(**args) @classmethod - def from_bitarray(cls, bit_arr: bitarray) -> "MessageType1": + def from_bitarray(cls, bit_arr: bitarray) -> "Payload": cur = 0 + end = 0 kwargs = {} # Iterate over the bits until the last bit of the bitarray or all fields are fully decoded for field in cls.fields(): + if end >= len(bit_arr): + kwargs[field.name] = None + continue + width = field.metadata['width'] d_type = field.metadata['d_type'] converter = field.metadata['to_converter'] @@ -424,9 +430,6 @@ def from_bitarray(cls, bit_arr: bitarray) -> "MessageType1": kwargs[field.name] = val - if end >= len(bit_arr): - break - cur = end return cls(**kwargs) @@ -464,6 +467,14 @@ def to_lat_lon(v: int) -> float: return round(float(v) / 600000.0, 6) +def from_lat_lon_600(v: int) -> float: + return float(v) * 600.0 + + +def to_lat_lon_600(v: int) -> float: + return round(float(v) / 600.0, 6) + + def from_course(v: int) -> float: return float(v) * 10.0 @@ -480,7 +491,6 @@ def to_mmsi(v: typing.Union[str, int]): return str(v).zfill(9) - @attr.s(slots=True) class MessageType1(Payload): """ @@ -558,7 +568,7 @@ class MessageType5(Payload): imo = bit_field(30, int, default=0) callsign = bit_field(42, str, default='') shipname = bit_field(120, str, default='') - shiptype = bit_field(8, int, default=0, converter=ShipType) + ship_type = bit_field(8, int, default=0, converter=ShipType) to_bow = bit_field(9, int, default=0) to_stern = bit_field(9, int, default=0) to_port = bit_field(6, int, default=0) @@ -602,13 +612,13 @@ class MessageType7(Payload): repeat = bit_field(2, int, default=0) mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) spare = bit_field(2, int, default=0) - mmsi1 = bit_field(30, int, default=0) + mmsi1 = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) mmsiseq1 = bit_field(2, int, default=0) - mmsi2 = bit_field(30, int, default=0) + mmsi2 = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) mmsiseq2 = bit_field(2, int, default=0) - mmsi3 = bit_field(30, int, default=0) + mmsi3 = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) mmsiseq3 = bit_field(2, int, default=0) - mmsi4 = bit_field(30, int, default=0) + mmsi4 = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) mmsiseq4 = bit_field(2, int, default=0) @@ -641,7 +651,7 @@ class MessageType9(Payload): accuracy = bit_field(1, int, default=0) lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) - course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) + course = bit_field(12, int, from_converter=from_course, to_converter=to_course, default=0) second = bit_field(6, int, default=0) reserved = bit_field(8, int, default=0) dte = bit_field(1, int, default=0) @@ -719,14 +729,14 @@ class MessageType15(Payload): repeat = bit_field(2, int, default=0) mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) spare_1 = bit_field(2, int, default=0) - mmsi1 = bit_field(30, int, default=0) + mmsi1 = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) type1_1 = bit_field(6, int, default=0) offset1_1 = bit_field(12, int, default=0) spare_2 = bit_field(2, int, default=0) type1_2 = bit_field(6, int, default=0) offset1_2 = bit_field(12, int, default=0) spare_3 = bit_field(2, int, default=0) - mmsi2 = bit_field(30, int, default=0) + mmsi2 = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) type2_1 = bit_field(6, int, default=0) offset2_1 = bit_field(12, int, default=0) spare_4 = bit_field(2, int, default=0) @@ -743,11 +753,11 @@ class MessageType16(Payload): mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) spare = bit_field(2, int, default=0) - mmsi1 = bit_field(30, int, default=0) + mmsi1 = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) offset1 = bit_field(12, int, default=0) increment1 = bit_field(10, int, default=0) - mmsi2 = bit_field(30, int, default=0) + mmsi2 = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) offset2 = bit_field(12, int, default=0) increment2 = bit_field(10, int, default=0) @@ -762,8 +772,8 @@ class MessageType17(Payload): repeat = bit_field(2, int, default=0) mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) spare_1 = bit_field(2, int, default=0) - lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) - lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) + lon = bit_field(18, int, from_converter=from_course, to_converter=to_course, default=0) + lat = bit_field(17, int, from_converter=from_course, to_converter=to_course, default=0) spare_2 = bit_field(5, int, default=0) data = bit_field(736, int, default=0, from_converter=int_to_bytes) @@ -782,7 +792,7 @@ class MessageType18(Payload): accuracy = bit_field(1, int, default=0) lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) - course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) + course = bit_field(12, int, from_converter=from_course, to_converter=to_course, default=0) heading = bit_field(9, int, default=0) second = bit_field(6, int, default=0) reserved_2 = bit_field(2, int, default=0) @@ -806,21 +816,21 @@ class MessageType19(Payload): repeat = bit_field(2, int, default=0) mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) reserved = bit_field(8, int, default=0) - speed = bit_field(10, int, from_converter=lambda v: float(v) * 10.0, default=0) + speed = bit_field(10, int, from_converter=from_course, to_converter=to_course, default=0) accuracy = bit_field(1, int, default=0) lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) - course = bit_field(12, int, from_converter=lambda v: float(v) * 10.0, default=0) + course = bit_field(12, int, from_converter=from_course, to_converter=to_course, default=0) heading = bit_field(9, int, default=0) second = bit_field(6, int, default=0) regional = bit_field(4, int, default=0) shipname = bit_field(120, str, default='') - shiptype = bit_field(8, int, default=0) + ship_type = bit_field(8, int, default=0, converter=ShipType) to_bow = bit_field(9, int, default=0) to_stern = bit_field(9, int, default=0) to_port = bit_field(6, int, default=0) to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0) + epfd = bit_field(4, int, default=0, converter=EpfdType) raim = bit_field(1, bool, default=0) dte = bit_field(1, bool, default=0) assigned = bit_field(1, int, default=0) @@ -869,7 +879,7 @@ class MessageType21(Payload): repeat = bit_field(2, int, default=0) mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) - aid_type = bit_field(5, int, default=0) + aid_type = bit_field(5, int, default=0, converter=NavAid) shipname = bit_field(120, str, default='') accuracy = bit_field(1, bool, default=0) lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) @@ -878,7 +888,7 @@ class MessageType21(Payload): to_stern = bit_field(9, int, default=0) to_port = bit_field(6, int, default=0) to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0) + epfd = bit_field(4, int, default=0, converter=EpfdType) second = bit_field(6, int, default=0) off_position = bit_field(1, bool, default=0) regional = bit_field(8, int, default=0) @@ -908,9 +918,9 @@ class MessageType22Addressed(Payload): # If it is addressed (addressed field is 1), # the same span of data is interpreted as two 30-bit MMSIs # beginning at bit offsets 69 and 104 respectively. - dest1 = bit_field(30, int, default=0) + dest1 = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) empty_1 = bit_field(5, int, default=0) - dest2 = bit_field(30, int, default=0) + dest2 = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) empty_2 = bit_field(5, int, default=0) addressed = bit_field(1, bool, default=0) @@ -939,10 +949,10 @@ class MessageType22Broadcast(Payload): # If the message is broadcast (addressed field is 0), # the ne_lon, ne_lat, sw_lon, and sw_lat fields are the # corners of a rectangular jurisdiction area over which control parameter - ne_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) - ne_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) - sw_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) - sw_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) + ne_lon = bit_field(18, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) + ne_lat = bit_field(17, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) + sw_lon = bit_field(18, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) + sw_lat = bit_field(17, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) addressed = bit_field(1, bool, default=0) band_a = bit_field(1, bool, default=0) @@ -969,6 +979,13 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payloa else: return MessageType22Broadcast.create(**kwargs) + @classmethod + def from_bitarray(cls, bit_arr: bitarray) -> "Payload": + if bit_arr[139]: + return MessageType22Addressed.from_bitarray(bit_arr) + else: + return MessageType22Broadcast.from_bitarray(bit_arr) + @attr.s(slots=True) class MessageType23(Payload): @@ -981,17 +998,17 @@ class MessageType23(Payload): mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) spare_1 = bit_field(2, int, default=0) - ne_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) - ne_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) - sw_lon = bit_field(18, int, from_converter=lambda v: float(v) * 10.0, default=0) - sw_lat = bit_field(17, int, from_converter=lambda v: float(v) * 10.0, default=0) + ne_lon = bit_field(18, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) + ne_lat = bit_field(17, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) + sw_lon = bit_field(18, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) + sw_lat = bit_field(17, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) - station_type = bit_field(4, int, default=0) - ship_type = bit_field(8, int, default=0) + station_type = bit_field(4, int, default=0, converter=StationType) + ship_type = bit_field(8, int, default=0, converter=ShipType) spare_2 = bit_field(22, int, default=0) - txrx = bit_field(2, int, default=0) - interval = bit_field(4, int, default=0) + txrx = bit_field(2, int, default=0, converter=TransmitMode) + interval = bit_field(4, int, default=0, converter=StationIntervals) quiet = bit_field(4, int, default=0) spare_3 = bit_field(6, int, default=0) @@ -1014,7 +1031,7 @@ class MessageType24PartB(Payload): mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) partno = bit_field(2, int, default=0) - shiptype = bit_field(8, int, default=0) + ship_type = bit_field(8, int, default=0) vendorid = bit_field(18, str, default=0) model = bit_field(4, int, default=0) serial = bit_field(20, int, default=0) @@ -1047,7 +1064,17 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payloa elif partno == 1: return MessageType24PartB.create(**kwargs) else: - raise ValueError(f"Partno {partno} is not allowed!") + raise ValueError(f"Partno {partno} is not allowed!") + + @classmethod + def from_bitarray(cls, bit_arr: bitarray) -> "Payload": + partno: int = get_int(bit_arr, 38, 40) + if partno == 0: + return MessageType24PartA.from_bitarray(bit_arr) + elif partno == 1: + return MessageType24PartB.from_bitarray(bit_arr) + else: + raise ValueError(f"Partno {partno} is not allowed!") @attr.s(slots=True) @@ -1059,7 +1086,7 @@ class MessageType25AddressedStructured(Payload): addressed = bit_field(1, bool, default=0) structured = bit_field(1, bool, default=0) - dest_mmsi = bit_field(30, int, default=0) + dest_mmsi = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) app_id = bit_field(16, int, default=0) data = bit_field(82, int, default=0, from_converter=int_to_bytes) @@ -1086,7 +1113,7 @@ class MessageType25AddressedUnstructured(Payload): addressed = bit_field(1, bool, default=0) structured = bit_field(1, bool, default=0) - dest_mmsi = bit_field(30, int, default=0) + dest_mmsi = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) data = bit_field(98, int, default=0, from_converter=int_to_bytes) @@ -1128,6 +1155,22 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payloa else: return MessageType25BroadcastUnstructured.create(**kwargs) + @classmethod + def from_bitarray(cls, bit_arr: bitarray) -> "Payload": + addressed: int = bit_arr[38] + structured: int = bit_arr[39] + + if addressed: + if structured: + return MessageType25AddressedStructured.from_bitarray(bit_arr) + else: + return MessageType25AddressedUnstructured.from_bitarray(bit_arr) + else: + if structured: + return MessageType25BroadcastStructured.from_bitarray(bit_arr) + else: + return MessageType25BroadcastUnstructured.from_bitarray(bit_arr) + @attr.s(slots=True) class MessageType26AddressedStructured(Payload): @@ -1138,7 +1181,7 @@ class MessageType26AddressedStructured(Payload): addressed = bit_field(1, bool, default=0) structured = bit_field(1, bool, default=0) - dest_mmsi = bit_field(30, int, default=0) + dest_mmsi = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) app_id = bit_field(16, int, default=0) data = bit_field(958, int, default=0, from_converter=int_to_bytes) radio = bit_field(20, int, default=0) @@ -1167,7 +1210,7 @@ class MessageType26AddressedUnstructured(Payload): addressed = bit_field(1, bool, default=0) structured = bit_field(1, bool, default=0) - dest_mmsi = bit_field(30, int, default=0) + dest_mmsi = bit_field(30, int, default=0, from_converter=from_mmsi, to_converter=to_mmsi) app_id = bit_field(16, int, default=0) data = bit_field(958, int, default=0, from_converter=int_to_bytes) radio = bit_field(20, int, default=0) @@ -1212,6 +1255,22 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payloa else: return MessageType26BroadcastUnstructured.create(**kwargs) + @classmethod + def from_bitarray(cls, bit_arr: bitarray) -> "Payload": + addressed: int = bit_arr[38] + structured: int = bit_arr[39] + + if addressed: + if structured: + return MessageType26AddressedStructured.from_bitarray(bit_arr) + else: + return MessageType26BroadcastStructured.from_bitarray(bit_arr) + else: + if structured: + return MessageType26AddressedUnstructured.from_bitarray(bit_arr) + else: + return MessageType26BroadcastUnstructured.from_bitarray(bit_arr) + @attr.s(slots=True) class MessageType27(Payload): @@ -1225,9 +1284,9 @@ class MessageType27(Payload): accuracy = bit_field(1, int, default=0) raim = bit_field(1, int, default=0) - status = bit_field(4, int, default=0) - lon = bit_field(18, int, from_converter=lambda v: float(v) * 600.0, default=0) - lat = bit_field(17, int, from_converter=lambda v: float(v) * 600.0, default=0) + status = bit_field(4, int, default=0, converter=NavigationStatus) + lon = bit_field(18, int, from_converter=from_lat_lon_600, to_converter=to_lat_lon_600, default=0) + lat = bit_field(17, int, from_converter=from_lat_lon_600, to_converter=to_lat_lon_600, default=0) speed = bit_field(6, int, default=0) course = bit_field(9, int, default=0) gnss = bit_field(1, int, default=0) diff --git a/tests/test_ais.py b/tests/test_ais.py index fd9afca..255045d 100644 --- a/tests/test_ais.py +++ b/tests/test_ais.py @@ -3,9 +3,8 @@ from pyais import NMEAMessage from pyais.ais_types import AISType -from pyais.constants import ManeuverIndicator, NavigationStatus, ShipType, NavAid, EpfdType +from pyais.constants import ManeuverIndicator, NavigationStatus, ShipType, NavAid, EpfdType, StationType, TransmitMode from pyais.decode import decode -from pyais.exceptions import UnknownMessageException from pyais.stream import ByteStream @@ -29,19 +28,19 @@ def test_to_json(self): text = textwrap.dedent("""{ "msg_type": 1, "repeat": 0, - "mmsi": 367533950, + "mmsi": "367533950", "status": 0, - "turn": 0, + "turn": -128, "speed": 0.0, "accuracy": 1, - "lon": 324.984195, + "lon": -122.408232, "lat": 37.808418, "course": 360.0, "heading": 511, "second": 34, "maneuver": 0, "spare": 0, - "raim": 1, + "raim": true, "radio": 34059 }""") self.assertEqual(json_dump, text) @@ -171,7 +170,7 @@ def test_msg_type_5(self): ).asdict() assert msg['callsign'] == "3FOF8" assert msg['shipname'] == "EVER DIADEM" - assert msg['shiptype'] == ShipType.Cargo + assert msg['ship_type'] == ShipType.Cargo assert msg['to_bow'] == 225 assert msg['to_stern'] == 70 assert msg['to_port'] == 1 @@ -192,26 +191,27 @@ def test_msg_type_6(self): assert msg['data'] == 258587390607345 def test_msg_type_7(self): - msg = NMEAMessage(b"!AIVDM,1,1,,A,702R5`hwCjq8,0*6B").decode() + msg = decode(b"!AIVDM,1,1,,A,702R5`hwCjq8,0*6B").asdict() + assert msg['mmsi'] == "002655651" + assert msg['msg_type'] == 7 assert msg['mmsi1'] == "265538450" + assert msg['mmsiseq1'] == 0 + assert msg['mmsi2'] is None + assert msg['mmsi3'] is None + assert msg['mmsi4'] is None def test_msg_type_8(self): - msg = NMEAMessage(b"!AIVDM,1,1,,A,85Mwp`1Kf3aCnsNvBWLi=wQuNhA5t43N`5nCuI=p5?Per18=HB1U:1@E=B0m5?Per18=HB1U:1@E=B0mJ8`J8`NhWAwwo862PaLELTBJ:V00000000S0D:R220,0*0B").decode() - assert msg['type'] == 19 + msg = decode(b"!AIVDM,1,1,,B,C5N3SRgPEnJGEBT>NhWAwwo862PaLELTBJ:V00000000S0D:R220,0*0B").asdict() + assert msg['msg_type'] == 19 assert msg['mmsi'] == "367059850" - assert round(msg['speed'], 1) == 8.7 + assert msg['speed'] == 8.7 assert msg['accuracy'] == 0 - assert round(msg['lat'], 2) == 29.54 - assert round(msg['lon'], 2) == -88.81 + assert msg['lat'] == 29.543695 + assert msg['lon'], 2 == -88.810394 assert round(msg['course'], 2) == 335.9 assert msg['heading'] == 511 assert msg['second'] == 46 assert msg['shipname'] == "CAPT.J.RIMES" - assert msg['shiptype'] == ShipType(70) + assert msg['ship_type'] == ShipType(70) + assert msg['ship_type'] == ShipType.Cargo assert msg['to_bow'] == 5 assert msg['to_stern'] == 21 assert msg['to_port'] == 4 assert msg['to_starboard'] == 4 + assert msg['epfd'] == EpfdType.GPS + assert msg['dte'] == 0 + assert msg['assigned'] == 0 def test_msg_type_20(self): - msg = NMEAMessage(b"!AIVDM,1,1,,A,D028rqPgOP00PH=JrN9l000?wB2HH;,0*44").decode() - assert msg['type'] == 22 + msg = decode(b"!AIVDM,1,1,,A,F@@W>gOP00PH=JrN9l000?wB2HH;,0*44").asdict() + assert msg['msg_type'] == 22 assert msg['mmsi'] == "017419965" assert msg['channel_a'] == 3584 assert msg['channel_b'] == 8 @@ -438,94 +458,114 @@ def test_msg_type_22(self): assert msg['band_b'] == 0 assert msg['zonesize'] == 4 - assert 'ne_lon' not in msg.content.keys() - assert 'ne_lat' not in msg.content.keys() - assert 'sw_lon' not in msg.content.keys() - assert 'sw_lat' not in msg.content.keys() + assert 'ne_lon' not in msg.keys() + assert 'ne_lat' not in msg.keys() + assert 'sw_lon' not in msg.keys() + assert 'sw_lat' not in msg.keys() def test_msg_type_23(self): - msg = NMEAMessage(b"!AIVDM,1,1,,B,G02:Kn01R`sn@291nj600000900,2*12").decode() - assert msg['type'] == 23 + msg = decode(b"!AIVDM,1,1,,B,G02:Kn01R`sn@291nj600000900,2*12").asdict() + assert msg['msg_type'] == 23 assert msg['mmsi'] == "002268120" assert msg['ne_lon'] == 157.8 - assert msg['shiptype'] == ShipType.NotAvailable - assert round(msg['ne_lat'], 1) == 3064.2 - assert round(msg['sw_lon'], 1) == 109.6 - assert round(msg['sw_lat'], 1) == 3040.8 + assert msg['ship_type'] == ShipType.NotAvailable + assert msg['ne_lat'] == 3064.2 + assert msg['sw_lon'] == 109.6 + assert msg['sw_lat'] == 3040.8 + assert msg['station_type'] == StationType.REGIONAL + assert msg['txrx'] == TransmitMode.TXA_TXB_RXA_RXB + assert msg['interval'] == 9 + assert msg['quiet'] == 0 def test_msg_type_24(self): - msg = NMEAMessage(b"!AIVDM,1,1,,A,H52KMeDU653hhhi0000000000000,0*1A").decode() - assert msg['type'] == 24 + msg = decode(b"!AIVDM,1,1,,A,H52KMeDU653hhhi0000000000000,0*1A").asdict() + assert msg['msg_type'] == 24 assert msg['mmsi'] == "338091445" assert msg['partno'] == 1 - assert msg['shiptype'] == ShipType.PleasureCraft + assert msg['ship_type'] == ShipType.PleasureCraft assert msg['vendorid'] == "FEC" assert msg['callsign'] == "" assert msg['to_bow'] == 0 assert msg['to_stern'] == 0 assert msg['to_port'] == 0 assert msg['to_starboard'] == 0 - assert msg['mothership_mmsi'] == "000000000" - def test_msg_type_25(self): - msg = NMEAMessage(b"!AIVDM,1,1,,A,I6SWo?8P00a3PKpEKEVj0?vNP<65,0*73").decode() + def test_msg_type_25_a(self): + msg = decode(b"!AIVDM,1,1,,A,I6SWo?8P00a3PKpEKEVj0?vNP<65,0*73").asdict() - assert msg['type'] == 25 + assert msg['msg_type'] == 25 assert msg['addressed'] assert not msg['structured'] assert msg['dest_mmsi'] == "134218384" - def test_msg_type_26(self): - msg = NMEAMessage(b"!AIVDM,1,1,,A,JB3R0GO7p>vQL8tjw0b5hqpd0706kh9d3lR2vbl0400,2*40").decode() - assert msg['type'] == 26 + def test_msg_type_25_b(self): + msg = decode(b"!AIVDO,1,1,,A,I6SWo?vQL8tjw0b5hqpd0706kh9d3lR2vbl0400,2*40").asdict() + assert msg['msg_type'] == 26 assert msg['addressed'] assert msg['structured'] assert msg['dest_mmsi'] == "838351848" + assert msg['data'] == 0x332fc0a85c39e2c007006cf026c0f4882faad001000 - msg = NMEAMessage(b"!AIVDM,1,1,,A,J0@00@370>t0Lh3P0000200H:2rN92,4*14").decode() - assert msg['type'] == 26 + def test_msg_type_26_b(self): + msg = decode(b"!AIVDM,1,1,,A,J0@00@370>t0Lh3P0000200H:2rN92,4*14").asdict() + assert msg['msg_type'] == 26 assert not msg['addressed'] assert not msg['structured'] + assert msg['data'] == 0xc700ef007300e0000000080018282e9e24 def test_msg_type_27(self): - msg = NMEAMessage(b"!AIVDM,1,1,,B,KC5E2b@U19PFdLbMuc5=ROv62<7m,0*16").decode(silent=False) - assert msg - assert msg['type'] == 27 + msg = decode(b"!AIVDM,1,1,,B,KC5E2b@U19PFdLbMuc5=ROv62<7m,0*16").asdict() + assert msg['msg_type'] == 27 assert msg['mmsi'] == "206914217" assert msg['accuracy'] == 0 assert msg['raim'] == 0 assert msg['status'] == NavigationStatus.NotUnderCommand - assert round(msg['lon'], 3) == 137.023 - assert round(msg['lat'], 2) == 4.84 + assert msg['lon'] == 137.023333 + assert msg['lat'] == 4.84 assert msg['speed'] == 57 assert msg['course'] == 167 assert msg['gnss'] == 0 def test_broken_messages(self): # Undefined epfd - assert NMEAMessage(b"!AIVDM,1,1,,B,4>O7m7Iu@<9qUfbtm`vSnwvH20S8,0*46").decode()['epfd'] == EpfdType.Undefined + assert decode(b"!AIVDM,1,1,,B,4>O7m7Iu@<9qUfbtm`vSnwvH20S8,0*46").asdict()['epfd'] == EpfdType.Undefined def test_multiline_message(self): # these messages caused issue #3 msg_1_part_0 = b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07' msg_1_part_1 = b'!AIVDM,2,2,1,A,F@V@00000000000,2*35' - assert NMEAMessage.assemble_from_iterable( - messages=[ - NMEAMessage(msg_1_part_0), - NMEAMessage(msg_1_part_1) - ] - ).decode().to_json() + assert decode(msg_1_part_0, msg_1_part_1).to_json() msg_2_part_0 = b'!AIVDM,2,1,9,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*0F' msg_2_part_1 = b'!AIVDM,2,2,9,A,F@V@00000000000,2*3D' - assert NMEAMessage.assemble_from_iterable( - messages=[ - NMEAMessage(msg_2_part_0), - NMEAMessage(msg_2_part_1) - ] - ).decode().to_json() + assert decode(msg_2_part_0, msg_2_part_1).to_json() def test_byte_stream(self): messages = [ @@ -536,78 +576,48 @@ def test_byte_stream(self): ] counter = 0 for msg in ByteStream(messages): - decoded = msg.decode() + decoded = decode(msg) assert decoded['shipname'] == 'NORDIC HAMBURG' assert decoded['mmsi'] == "210035000" assert decoded counter += 1 assert counter == 2 - def test_fail_silently(self): - # this tests combines testing for an UnknownMessageException and the silent param at once - msg = b"!AIVDM,1,1,,A,U31<0OOP000CshrMdl600?wP00SL,0*43" - nmea = NMEAMessage(msg) - - with self.assertRaises(UnknownMessageException): - nmea.decode(silent=False) - - # by default errors are ignored and an empty AIS message is returned - assert nmea.decode() is not None - assert isinstance(nmea.decode(), AISMessage) - text = """{ - "nmea": { - "ais_id": 37, - "raw": "!AIVDM,1,1,,A,U31<0OOP000CshrMdl600?wP00SL,0*43", - "talker": "AI", - "type": "VDM", - "message_fragments": 1, - "fragment_number": 1, - "message_id": null, - "channel": "A", - "payload": "U31<0OOP000CshrMdl600?wP00SL", - "fill_bits": 0, - "checksum": 67, - "bit_array": "100101000011000001001100000000011111011111100000000000000000000000010011111011110000111010011101101100110100000110000000000000001111111111100000000000000000100011011100" - }, - "decoded": {} -}""" - self.assertEqual(nmea.decode().to_json(), text) - def test_empty_channel(self): msg = b"!AIVDO,1,1,,,B>qc:003wk?8mP=18D3Q3wgTiT;T,0*13" self.assertEqual(NMEAMessage(msg).channel, "") - content = decode_msg(msg) - self.assertEqual(content["type"], 18) + content = decode(msg).asdict() + self.assertEqual(content["msg_type"], 18) self.assertEqual(content["repeat"], 0) self.assertEqual(content["mmsi"], "1000000000") - self.assertEqual(format(content["speed"], ".1f"), "102.3") + self.assertEqual(content["speed"], 1023) self.assertEqual(content["accuracy"], 0) self.assertEqual(str(content["lon"]), "181.0") self.assertEqual(str(content["lat"]), "91.0") self.assertEqual(str(content["course"]), "360.0") self.assertEqual(content["heading"], 511) self.assertEqual(content["second"], 31) - self.assertEqual(content["regional"], 0) + self.assertEqual(content["reserved_2"], 0) self.assertEqual(content["cs"], 1) self.assertEqual(content["display"], 0) self.assertEqual(content["band"], 1) self.assertEqual(content["radio"], 410340) def test_msg_with_more_that_82_chars_payload(self): - content = decode_msg( + content = decode( "!AIVDM,1,1,,B,53ktrJ82>ia4=50<0020<5=@Dhv0t8T@u<0000001PV854Si0;mR@CPH13p0hDm1C3h0000,2*35" - ) + ).asdict() - self.assertEqual(content["type"], 5) + self.assertEqual(content["msg_type"], 5) self.assertEqual(content["mmsi"], "255801960") self.assertEqual(content["repeat"], 0) self.assertEqual(content["ais_version"], 2) self.assertEqual(content["imo"], 9356945) self.assertEqual(content["callsign"], "CQPC") self.assertEqual(content["shipname"], "CASTELO OBIDOS") - self.assertEqual(content["shiptype"], ShipType.NotAvailable) + self.assertEqual(content["ship_type"], ShipType.NotAvailable) self.assertEqual(content["to_bow"], 12) self.assertEqual(content["to_stern"], 38) self.assertEqual(content["to_port"], 8) diff --git a/tests/test_encode.py b/tests/test_encode.py index dc698bb..a1ba85a 100644 --- a/tests/test_encode.py +++ b/tests/test_encode.py @@ -2,7 +2,7 @@ import bitarray -from pyais import encode_dict, encode_payload +from pyais import encode_dict, encode_msg from pyais.decode import decode from pyais.encode import data_to_payload, get_ais_type, ENCODE_MSG from pyais.messages import MessageType1, MessageType26BroadcastUnstructured, MessageType26AddressedUnstructured, \ @@ -127,12 +127,12 @@ def test_invalid_talker_id(): def test_encode_payload_invalid_talker_id(): with unittest.TestCase().assertRaises(ValueError) as err: - encode_payload({'mmsi': 123456}, talker_id="AIDDD") + encode_msg({'mmsi': 123456}, talker_id="AIDDD") assert str(err.exception) == "talker_id must be any of ['AIVDM', 'AIVDO']" with unittest.TestCase().assertRaises(ValueError) as err: - encode_payload({'mmsi': 123456}, talker_id=None) + encode_msg({'mmsi': 123456}, talker_id=None) assert str(err.exception) == "talker_id must be any of ['AIVDM', 'AIVDO']" @@ -151,12 +151,12 @@ def test_invalid_radio_channel(): def test_encode_payload_error_radio(): with unittest.TestCase().assertRaises(ValueError) as err: - encode_payload({'mmsi': 123456}, radio_channel="C") + encode_msg({'mmsi': 123456}, radio_channel="C") assert str(err.exception) == "radio_channel must be any of ['A', 'B']" with unittest.TestCase().assertRaises(ValueError) as err: - encode_payload({'mmsi': 123456}, radio_channel=None) + encode_msg({'mmsi': 123456}, radio_channel=None) assert str(err.exception) == "radio_channel must be any of ['A', 'B']" @@ -343,7 +343,7 @@ def test_encode_type_24_b(): 'partno': 1, 'repeat': 0, 'serial': 199729, - 'shiptype': 37, # PleasureCraft + 'ship_type': 37, # PleasureCraft 'to_bow': 0, 'to_port': 0, 'to_starboard': 0, @@ -501,7 +501,7 @@ def test_encode_type_19(): } encoded = encode_dict(data) - assert encoded[0] == "!AIVDO,1,1,,A,C5N3SRP0=nJGEBT>NhWAwwo862PaLELTBJ:V00000000S0D:R220,0*25" + assert encoded[0] == "!AIVDO,1,1,,A,C5N3SRP0=nJGEBT>NhWAwwo862PaLELTBJ:V0000000000D:R220,0*46" def test_encode_type_18(): @@ -822,8 +822,8 @@ def test_encode_type_5(): } encoded_part_1 = encode_dict(data, radio_channel="B", talker_id="AIVDM")[0] - encoded_part_2 = encode_dict(data, radio_channel="B", talker_id="AIVDM")[1] - assert encoded_part_1 == "!AIVDM,2,1,,B,55?MbV02;H;s1@U@4pT>1@U@40000000000000000000000,2*56" assert encoded[1] == "!AIVDO,2,2,,A,0000000000,2*26" From 448816c076a099f991f4b5b596f198d20dd838dd Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sat, 29 Jan 2022 16:48:26 +0100 Subject: [PATCH 05/18] Fix type errors and bugs --- pyais/decode.py | 45 ++----------- pyais/encode.py | 38 +---------- pyais/main.py | 4 +- pyais/messages.py | 123 +++++++++++++++++++++++++++++------ tests/test_ais.py | 18 ++++- tests/test_decode_raw.py | 33 +++++----- tests/test_encode.py | 12 ++-- tests/test_file_stream.py | 4 +- tests/test_generic_stream.py | 2 +- tests/test_nmea.py | 10 +-- 10 files changed, 155 insertions(+), 134 deletions(-) diff --git a/pyais/decode.py b/pyais/decode.py index c056c44..762605c 100644 --- a/pyais/decode.py +++ b/pyais/decode.py @@ -1,41 +1,7 @@ import typing -from pyais.exceptions import UnknownMessageException, TooManyMessagesException, MissingMultipartMessageException -from pyais.messages import MessageType1, MessageType2, MessageType3, MessageType4, MessageType5, MessageType6, \ - MessageType7, MessageType8, MessageType9, MessageType10, MessageType11, MessageType12, MessageType13, MessageType14, \ - MessageType15, MessageType16, MessageType17, MessageType18, MessageType19, MessageType20, MessageType21, \ - MessageType22, MessageType23, MessageType24, MessageType25, MessageType26, MessageType27, NMEAMessage - -DECODE_MSG = { - 0: MessageType1, # there are messages with a zero (0) as an id. these seem to be the same as type 1 messages - 1: MessageType1, - 2: MessageType2, - 3: MessageType3, - 4: MessageType4, - 5: MessageType5, - 6: MessageType6, - 7: MessageType7, - 8: MessageType8, - 9: MessageType9, - 10: MessageType10, - 11: MessageType11, - 12: MessageType12, - 13: MessageType13, - 14: MessageType14, - 15: MessageType15, - 16: MessageType16, - 17: MessageType17, - 18: MessageType18, - 19: MessageType19, - 20: MessageType20, - 21: MessageType21, - 22: MessageType22, - 23: MessageType23, - 24: MessageType24, - 25: MessageType25, - 26: MessageType26, - 27: MessageType27, -} +from pyais.exceptions import TooManyMessagesException, MissingMultipartMessageException +from pyais.messages import NMEAMessage, ANY_MESSAGE def _assemble_messages(*args: bytes) -> NMEAMessage: @@ -63,10 +29,7 @@ def _assemble_messages(*args: bytes) -> NMEAMessage: return final -def decode(*args: typing.Union[str, bytes]): +def decode(*args: typing.Union[str, bytes]) -> ANY_MESSAGE: parts = tuple(msg.encode('utf-8') if isinstance(msg, str) else msg for msg in args) nmea = _assemble_messages(*parts) - try: - return DECODE_MSG[nmea.ais_id].from_bitarray(nmea.bit_array) - except IndexError as e: - raise UnknownMessageException(f"The message {nmea} is not supported!") from e + return nmea.decode() diff --git a/pyais/encode.py b/pyais/encode.py index 2ec6982..4d4fd5a 100644 --- a/pyais/encode.py +++ b/pyais/encode.py @@ -1,47 +1,13 @@ import math import typing -from pyais.messages import MessageType1, MessageType27, MessageType26, MessageType25, MessageType24, MessageType23, \ - MessageType22, MessageType21, MessageType20, MessageType19, MessageType18, MessageType17, MessageType16, \ - MessageType15, MessageType14, MessageType13, MessageType12, MessageType11, MessageType10, MessageType9, \ - MessageType2, MessageType3, MessageType4, MessageType5, MessageType6, MessageType7, MessageType8, Payload +from pyais.messages import Payload, MSG_CLASS from pyais.util import chunks, compute_checksum # Types DATA_DICT = typing.Dict[str, typing.Union[str, int, float, bytes, bool]] AIS_SENTENCES = typing.List[str] -ENCODE_MSG = { - 0: MessageType1, # there are messages with a zero (0) as an id. these seem to be the same as type 1 messages - 1: MessageType1, - 2: MessageType2, - 3: MessageType3, - 4: MessageType4, - 5: MessageType5, - 6: MessageType6, - 7: MessageType7, - 8: MessageType8, - 9: MessageType9, - 10: MessageType10, - 11: MessageType11, - 12: MessageType12, - 13: MessageType13, - 14: MessageType14, - 15: MessageType15, - 16: MessageType16, - 17: MessageType17, - 18: MessageType18, - 19: MessageType19, - 20: MessageType20, - 21: MessageType21, - 22: MessageType22, - 23: MessageType23, - 24: MessageType24, - 25: MessageType25, - 26: MessageType26, - 27: MessageType27, -} - def get_ais_type(data: DATA_DICT) -> int: """ @@ -62,7 +28,7 @@ def get_ais_type(data: DATA_DICT) -> int: def data_to_payload(ais_type: int, data: DATA_DICT) -> Payload: try: - return ENCODE_MSG[ais_type].create(**data) + return MSG_CLASS[ais_type].create(**data) except KeyError as err: raise ValueError(f"AIS message type {ais_type} is not supported") from err diff --git a/pyais/main.py b/pyais/main.py index de33b7f..0931911 100644 --- a/pyais/main.py +++ b/pyais/main.py @@ -99,7 +99,7 @@ def decode_from_socket(args: argparse.Namespace) -> int: with stream_cls(args.destination, args.port) as s: try: for msg in s: - decoded_message = msg.decode(silent=True) + decoded_message = msg.decode() print(decoded_message, file=args.out_file) except KeyboardInterrupt: # Catch KeyboardInterrupts in order to close the socket and free associated resources @@ -131,7 +131,7 @@ def decode_from_file(args: argparse.Namespace) -> int: with BinaryIOStream(file) as s: try: for msg in s: - decoded_message = msg.decode(silent=True) + decoded_message = msg.decode() print(decoded_message, file=args.out_file) except KeyboardInterrupt: # Catch KeyboardInterrupts in order to close the file descriptor and free associated resources diff --git a/pyais/messages.py b/pyais/messages.py index bf154ad..0ebab89 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -8,7 +8,7 @@ from pyais.constants import TalkerID, NavigationStatus, ManeuverIndicator, EpfdType, ShipType, NavAid, StationType, \ TransmitMode, StationIntervals -from pyais.exceptions import InvalidNMEAMessageException +from pyais.exceptions import InvalidNMEAMessageException, UnknownMessageException from pyais.util import decode_into_bit_array, compute_checksum, deprecated, int_to_bin, str_to_bin, \ encode_ascii_6, from_bytes, int_to_bytes, from_bytes_signed, decode_bin_as_ascii6, get_int @@ -113,7 +113,7 @@ def bit_field(width: int, d_type: typing.Type[typing.Any], to_converter: typing.Optional[typing.Callable[[typing.Any], typing.Any]] = None, default: typing.Optional[typing.Any] = None, signed: bool = False, - **kwargs) -> typing.Any: + **kwargs: typing.Any) -> typing.Any: """ Simple wrapper around the attr.ib interface to be used in conjunction with the Payload class. @@ -122,6 +122,7 @@ def bit_field(width: int, d_type: typing.Type[typing.Any], @param from_converter: Optional converter function called **before** encoding @param to_converter: Optional converter function called **after** decoding @param default: Optional default value to be used when no value is explicitly passed. + @param signed: Set to true if the value is a signed integer @return: An attr.ib field instance. """ return attr.ib( @@ -321,6 +322,22 @@ def data(self) -> bytes: """ return self.payload + def decode(self) -> "ANY_MESSAGE": + """ + Decode the NMEA message. + @return: The decoded message class as a superclass of `Payload`. + + >>> nmea = NMEAMessage(b"!AIVDO,1,1,,,B>qc:003wk?8mP=18D3Q3wgTiT;T,0*13").decode() + MessageType18(msg_type=18, repeat=0, mmsi='1000000000', reserved=0, speed=1023, + accuracy=0, lon=181.0, lat=91.0, course=360.0, heading=511, second=31, + reserved_2=0, cs=True, display=False, dsc=False, band=True, msg22=True, + assigned=False, raim=False, radio=410340) + """ + try: + return MSG_CLASS[self.ais_id].from_bitarray(self.bit_array) + except KeyError as e: + raise UnknownMessageException(f"The message {self} is not supported!") from e + @attr.s(slots=True) class Payload(abc.ABC): @@ -371,7 +388,7 @@ def encode(self) -> typing.Tuple[str, int]: return encode_ascii_6(bit_arr) @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": + def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_MESSAGE": """ Create a new instance of each Payload class. @param kwargs: A set of keywords. For each field of `cls` a keyword with the same @@ -392,13 +409,13 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payloa default = field.metadata['default'] if default is not None: args[key] = default - return cls(**args) + return cls(**args) # type:ignore @classmethod - def from_bitarray(cls, bit_arr: bitarray) -> "Payload": - cur = 0 - end = 0 - kwargs = {} + def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": + cur: int = 0 + end: int = 0 + kwargs: typing.Dict[str, typing.Any] = {} # Iterate over the bits until the last bit of the bitarray or all fields are fully decoded for field in cls.fields(): @@ -413,6 +430,7 @@ def from_bitarray(cls, bit_arr: bitarray) -> "Payload": end = min(len(bit_arr), cur + width) bits = bit_arr[cur: end] + val: typing.Any # Get the correct data type and decoding function if d_type == int or d_type == bool: shift = (8 - ((end - cur) % 8)) % 8 @@ -432,9 +450,9 @@ def from_bitarray(cls, bit_arr: bitarray) -> "Payload": cur = end - return cls(**kwargs) + return cls(**kwargs) # type:ignore - def asdict(self): + def asdict(self) -> typing.Dict[str, typing.Any]: d = {} for field in self.fields(): d[field.name] = getattr(self, field.name) @@ -452,7 +470,7 @@ def to_json(self) -> str: # def from_speed(v: int) -> NavigationStatus: - return NavigationStatus(float(v) * 10.0) + return NavigationStatus(int(v * 10.0)) def to_speed(v: int) -> float: @@ -483,11 +501,11 @@ def to_course(v: int) -> float: return v / 10.0 -def from_mmsi(v: typing.Union[str, int]): +def from_mmsi(v: typing.Union[str, int]) -> int: return int(v) -def to_mmsi(v: typing.Union[str, int]): +def to_mmsi(v: typing.Union[str, int]) -> str: return str(v).zfill(9) @@ -973,14 +991,14 @@ class MessageType22(Payload): """ @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": + def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_MESSAGE": if kwargs.get('addressed', False): return MessageType22Addressed.create(**kwargs) else: return MessageType22Broadcast.create(**kwargs) @classmethod - def from_bitarray(cls, bit_arr: bitarray) -> "Payload": + def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": if bit_arr[139]: return MessageType22Addressed.from_bitarray(bit_arr) else: @@ -1057,7 +1075,7 @@ class MessageType24(Payload): """ @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": + def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_MESSAGE": partno: int = int(kwargs.get('partno', 0)) if partno == 0: return MessageType24PartA.create(**kwargs) @@ -1067,7 +1085,7 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payloa raise ValueError(f"Partno {partno} is not allowed!") @classmethod - def from_bitarray(cls, bit_arr: bitarray) -> "Payload": + def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": partno: int = get_int(bit_arr, 38, 40) if partno == 0: return MessageType24PartA.from_bitarray(bit_arr) @@ -1140,7 +1158,7 @@ class MessageType25(Payload): """ @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": + def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_MESSAGE": addressed = kwargs.get('addressed', False) structured = kwargs.get('structured', False) @@ -1156,7 +1174,7 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payloa return MessageType25BroadcastUnstructured.create(**kwargs) @classmethod - def from_bitarray(cls, bit_arr: bitarray) -> "Payload": + def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": addressed: int = bit_arr[38] structured: int = bit_arr[39] @@ -1240,7 +1258,7 @@ class MessageType26(Payload): """ @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payload": + def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_MESSAGE": addressed = kwargs.get('addressed', False) structured = kwargs.get('structured', False) @@ -1256,7 +1274,7 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "Payloa return MessageType26BroadcastUnstructured.create(**kwargs) @classmethod - def from_bitarray(cls, bit_arr: bitarray) -> "Payload": + def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": addressed: int = bit_arr[38] structured: int = bit_arr[39] @@ -1291,3 +1309,66 @@ class MessageType27(Payload): course = bit_field(9, int, default=0) gnss = bit_field(1, int, default=0) spare = bit_field(1, int, default=0) + + +MSG_CLASS = { + 0: MessageType1, # there are messages with a zero (0) as an id. these seem to be the same as type 1 messages + 1: MessageType1, + 2: MessageType2, + 3: MessageType3, + 4: MessageType4, + 5: MessageType5, + 6: MessageType6, + 7: MessageType7, + 8: MessageType8, + 9: MessageType9, + 10: MessageType10, + 11: MessageType11, + 12: MessageType12, + 13: MessageType13, + 14: MessageType14, + 15: MessageType15, + 16: MessageType16, + 17: MessageType17, + 18: MessageType18, + 19: MessageType19, + 20: MessageType20, + 21: MessageType21, + 22: MessageType22, + 23: MessageType23, + 24: MessageType24, + 25: MessageType25, + 26: MessageType26, + 27: MessageType27, +} + +# This is type hint for all messages +ANY_MESSAGE = typing.Union[ + MessageType1, + MessageType2, + MessageType3, + MessageType4, + MessageType5, + MessageType6, + MessageType7, + MessageType8, + MessageType9, + MessageType10, + MessageType11, + MessageType12, + MessageType13, + MessageType14, + MessageType15, + MessageType16, + MessageType17, + MessageType18, + MessageType19, + MessageType20, + MessageType21, + MessageType22, + MessageType23, + MessageType24, + MessageType25, + MessageType26, + MessageType27, +] diff --git a/tests/test_ais.py b/tests/test_ais.py index 255045d..bb35a92 100644 --- a/tests/test_ais.py +++ b/tests/test_ais.py @@ -5,6 +5,8 @@ from pyais.ais_types import AISType from pyais.constants import ManeuverIndicator, NavigationStatus, ShipType, NavAid, EpfdType, StationType, TransmitMode from pyais.decode import decode +from pyais.exceptions import UnknownMessageException +from pyais.messages import MessageType18 from pyais.stream import ByteStream @@ -187,7 +189,7 @@ def test_msg_type_6(self): assert msg['mmsi'] == "150834090" assert msg['dac'] == 669 assert msg['fid'] == 11 - assert msg['retransmit'] == False + assert not msg['retransmit'] assert msg['data'] == 258587390607345 def test_msg_type_7(self): @@ -576,7 +578,7 @@ def test_byte_stream(self): ] counter = 0 for msg in ByteStream(messages): - decoded = decode(msg) + decoded = msg.decode().asdict() assert decoded['shipname'] == 'NORDIC HAMBURG' assert decoded['mmsi'] == "210035000" assert decoded @@ -629,3 +631,15 @@ def test_msg_with_more_that_82_chars_payload(self): self.assertEqual(content["minute"], 0) self.assertEqual(content["draught"], 4.7) self.assertEqual(content["destination"], "VIANA DO CASTELO") + + def test_nmea_decode(self): + nmea = NMEAMessage(b"!AIVDO,1,1,,,B>qc:003wk?8mP=18D3Q3wgTiT;T,0*13") + decoded = nmea.decode() + assert decoded.msg_type == 18 + assert isinstance(decoded, MessageType18) + + def test_nmea_decode_unknown_msg(self): + with self.assertRaises(UnknownMessageException): + nmea = NMEAMessage(b"!AIVDO,1,1,,,B>qc:003wk?8mP=18D3Q3wgTiT;T,0*13") + nmea.ais_id = 28 + nmea.decode() diff --git a/tests/test_decode_raw.py b/tests/test_decode_raw.py index 781e2c5..e29ac0b 100644 --- a/tests/test_decode_raw.py +++ b/tests/test_decode_raw.py @@ -1,13 +1,13 @@ import unittest -from pyais import decode_msg +from pyais.decode import decode from pyais.exceptions import InvalidNMEAMessageException, MissingMultipartMessageException, TooManyMessagesException class TestDecode(unittest.TestCase): def test_bytes_valid(self): - msg = decode_msg(b"!AIVDM,1,1,,A,403Ovl@000Htt02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', ) - self.assertIsInstance(decoded, dict) - self.assertEqual(decoded["mmsi"], "210035000") - self.assertEqual(decoded["callsign"], "5BXT2") - self.assertEqual(decoded["shipname"], "NORDIC HAMBURG") - self.assertEqual(decoded["destination"], "CTT-LAYBY") + self.assertEqual(decoded.mmsi, "210035000") + self.assertEqual(decoded.callsign, "5BXT2") + self.assertEqual(decoded.shipname, "NORDIC HAMBURG") + self.assertEqual(decoded.destination, "CTT-LAYBY") def test_too_many_messages(self): with self.assertRaises(TooManyMessagesException) as err: - decode_msg( + decode( b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', b'!AIVDM,2,1,9,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*0F', @@ -80,20 +79,20 @@ def test_multipart_error_message(self): msg_2 = "!AIVDM,2,2,0,A,00000000000,2*24" with self.assertRaises(MissingMultipartMessageException) as err: - decode_msg(msg_1) + decode(msg_1) self.assertEqual(str(err.exception), "Missing fragment numbers: [2]") with self.assertRaises(MissingMultipartMessageException) as err: - decode_msg(msg_2) + decode(msg_2) self.assertEqual(str(err.exception), "Missing fragment numbers: [1]") with self.assertRaises(MissingMultipartMessageException) as err: - decode_msg( + decode( "!AIVDM,3,2,0,A,539p4OT00000@7W3K@08ThiLE8@E:0000000001S0h9135Pl?0R0C@UDQp00,0*68", ) self.assertEqual(str(err.exception), "Missing fragment numbers: [1, 3]") - decode_msg( + decode( msg_1, msg_2 ) diff --git a/tests/test_encode.py b/tests/test_encode.py index a1ba85a..ac3b25d 100644 --- a/tests/test_encode.py +++ b/tests/test_encode.py @@ -4,13 +4,14 @@ from pyais import encode_dict, encode_msg from pyais.decode import decode -from pyais.encode import data_to_payload, get_ais_type, ENCODE_MSG +from pyais.encode import data_to_payload, get_ais_type from pyais.messages import MessageType1, MessageType26BroadcastUnstructured, MessageType26AddressedUnstructured, \ MessageType26BroadcastStructured, MessageType26AddressedStructured, MessageType25BroadcastUnstructured, \ MessageType25AddressedUnstructured, MessageType25BroadcastStructured, MessageType25AddressedStructured, \ MessageType24PartB, MessageType24PartA, MessageType22Broadcast, MessageType22Addressed, MessageType27, \ MessageType23, MessageType21, MessageType20, MessageType19, MessageType18, MessageType17, MessageType16, \ - MessageType15, MessageType4, MessageType5, MessageType6, MessageType7, MessageType8, MessageType2, MessageType3 + MessageType15, MessageType4, MessageType5, MessageType6, MessageType7, MessageType8, MessageType2, MessageType3, \ + MSG_CLASS from pyais.util import decode_bin_as_ascii6, decode_into_bit_array, str_to_bin, int_to_bin, to_six_bit, encode_ascii_6, \ int_to_bytes @@ -106,7 +107,7 @@ def test_encode_msg_table(): """ Make sure that each message number as the correct Message class associated """ - for k, v in list(ENCODE_MSG.items())[1:]: + for k, v in list(MSG_CLASS.items())[1:]: if k < 10: assert str(k) == v.__name__[-1:] else: @@ -232,11 +233,12 @@ def test_int_to_bin(): assert num == "11111111" assert len(num) == 8 + @unittest.skip("TODO") def test_decode_encode(): """Create each message with default values and test that it can be decoded again""" mmsi = 123 - for typ in ENCODE_MSG.keys(): + for typ in MSG_CLASS.keys(): encoded = encode_dict({'mmsi': mmsi, 'dest_mmsi': 656634123, 'type': typ}) decoded = decode(*encoded).asdict() @@ -822,7 +824,7 @@ def test_encode_type_5(): } encoded_part_1 = encode_dict(data, radio_channel="B", talker_id="AIVDM")[0] - encoded_part_2 = encode_dict(data, radio_channel="B", talker_id="AIVDM")[1]# + encoded_part_2 = encode_dict(data, radio_channel="B", talker_id="AIVDM")[1] # assert encoded_part_1 == "!AIVDM,2,1,,B,55?MbV02;H;s Date: Sun, 30 Jan 2022 16:47:39 +0100 Subject: [PATCH 06/18] Start to update documentation --- examples/encode.py | 17 ----- examples/encode_dict.py | 49 ++++++++++++ examples/encode_msg.py | 38 +++++++++ examples/file_stream.py | 14 +++- examples/out_of_order.py | 14 +++- examples/sample.ais | 5 +- pyais/__init__.py | 13 +++- pyais/stream.py | 157 ++++++++++++++++++-------------------- tests/test_file_stream.py | 2 +- 9 files changed, 197 insertions(+), 112 deletions(-) delete mode 100644 examples/encode.py create mode 100644 examples/encode_dict.py create mode 100644 examples/encode_msg.py diff --git a/examples/encode.py b/examples/encode.py deleted file mode 100644 index 4d4695f..0000000 --- a/examples/encode.py +++ /dev/null @@ -1,17 +0,0 @@ -from pyais.encode import MessageType5, encode_msg - -from pyais.encode import encode_dict - -data = { - 'course': 219.3, - 'lat': 37.802, - 'lon': -122.341, - 'mmsi': '366053209', - 'type': 1 -} -encoded = encode_dict(data, radio_channel="B", talker_id="AIVDM")[0] - -# It is also possible to create messages directly and pass them to `encode_payload` -payload = MessageType5.create(mmsi="123", shipname="Titanic", callsign="TITANIC", destination="New York") -encoded = encode_msg(payload) -print(encoded) diff --git a/examples/encode_dict.py b/examples/encode_dict.py new file mode 100644 index 0000000..a214426 --- /dev/null +++ b/examples/encode_dict.py @@ -0,0 +1,49 @@ +""" +The following example shows how to create an AIS message using a dictionary of values. + +The full specification of the AIVDM/AIVDO protocol is out of the scope of this example. +For a good overview of the AIVDM/AIVDO Sentence Layer please refer to this project: https://gpsd.gitlab.io/gpsd/AIVDM.html#_aivdmaivdo_sentence_layer + +But you should keep the following things in mind: + +- AIS messages are part of a two layer protocol +- the outer layer is the NMEA 0183 data exchange format +- the actual AIS message is part of the NMEA 0183’s 82-character payload +- because some AIS messages are larger than 82 characters they need to be split across several fragments +- there are 27 different types of AIS messages which differ in terms of fields + +Now to the actual encoding of messages: It is possible to encode a dictionary of values into an AIS message. +To do so, you need some values that you want to encode. The keys need to match the interface of the actual message. +You can call `.fields()` on any message class, to get glimpse on the available fields for each message type. +Unknown keys in the dict are simply omitted by pyais. Most keys have default values and do not need to +be passed explicitly. Only the keys `type` and `mmsi` are always required + +For the following example, let's assume that we want to create a type 1 AIS message. +""" +# Required imports +from pyais.encode import encode_dict +from pyais.messages import MessageType1 + +# This statement tells us which fields can be set for messages of type 1 +print(MessageType1.fields()) + +# A dictionary of fields that we want to encode +# Note that you can pass many more fields for type 1 messages, but we let pyais +# use default values for those keys +data = { + 'course': 219.3, + 'lat': 37.802, + 'lon': -122.341, + 'mmsi': '366053209', + 'type': 1 +} + +# This creates an encoded AIS message +# Note, that `encode_dict` returns always a list of fragments. +# This is done, because you may never know if a message fits into the 82 character +# size limit of payloads +encoded = encode_dict(data) +print(encoded) + +# You can also change the NMEA fields like the radio channel: +print(encode_dict(data, radio_channel="B")) diff --git a/examples/encode_msg.py b/examples/encode_msg.py new file mode 100644 index 0000000..54ca31e --- /dev/null +++ b/examples/encode_msg.py @@ -0,0 +1,38 @@ +""" +The following example shows how to create an AIS message using a dictionary of values. + +The full specification of the AIVDM/AIVDO protocol is out of the scope of this example. +For a good overview of the AIVDM/AIVDO Sentence Layer please refer to this project: https://gpsd.gitlab.io/gpsd/AIVDM.html#_aivdmaivdo_sentence_layer + +But you should keep the following things in mind: + +- AIS messages are part of a two layer protocol +- the outer layer is the NMEA 0183 data exchange format +- the actual AIS message is part of the NMEA 0183’s 82-character payload +- because some AIS messages are larger than 82 characters they need to be split across several fragments +- there are 27 different types of AIS messages which differ in terms of fields + +Now to the actual encoding of messages: It is possible to create a payload class and encode it. + +For the following example, let's assume that we want to create a type 1 AIS message. +""" +# Required imports +from pyais.encode import encode_msg +from pyais.messages import MessageType1 + +# You do not need to pass every attribute to the class. +# All field other than `mmsi` do have default values. +msg = MessageType1.create(course=219.3, lat=37.802, lon=-122.341, mmsi='366053209') + +# WARNING: If you try to instantiate the class directly (without using .create()) +# you need to pass all attributes, as no default values are used. + +# This creates an encoded AIS message +# Note, that `encode_msg` returns always a list of fragments. +# This is done, because you may never know if a message fits into the 82 character +# size limit of payloads +encoded = encode_msg(msg) +print(encoded) + +# You can also change the NMEA fields like the radio channel: +print(encode_msg(msg, radio_channel="B")) diff --git a/examples/file_stream.py b/examples/file_stream.py index 2c8caa2..ebe79d0 100644 --- a/examples/file_stream.py +++ b/examples/file_stream.py @@ -1,8 +1,16 @@ +""" +The following example shows how to read and parse AIS messages from a file. + +When reading a file, the following things are important to know: + +- lines that begin with a `#` are ignored +- invalid messages are skipped +- invalid lines are skipped +""" from pyais.stream import FileReaderStream filename = "sample.ais" for msg in FileReaderStream(filename): - decoded_message = msg.decode() - ais_content = decoded_message.content - # Do something with the ais message + decoded = msg.decode() + print(decoded) diff --git a/examples/out_of_order.py b/examples/out_of_order.py index 23bfe1c..48abd2a 100644 --- a/examples/out_of_order.py +++ b/examples/out_of_order.py @@ -1,11 +1,19 @@ -from pyais.stream import OutOfOrderByteStream +""" +The following example shows how to deal with messages that are out of order. + +In many cases it is not guaranteed that the messages arrive in the correct order. +The most prominent example would be UDP. For this use case, there is the `OutOfOrderByteStream` +class. You can pass any number of messages as an iterable into this class and it will +handle the assembly of the messages. +""" +from pyais.stream import IterMessages messages = [ b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', - b'!AIVDM,2,1,9,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*0F', b'!AIVDM,2,2,9,A,F@V@00000000000,2*3D', + b'!AIVDM,2,1,9,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*0F', ] -for msg in OutOfOrderByteStream(messages): +for msg in IterMessages(messages): print(msg.decode()) diff --git a/examples/sample.ais b/examples/sample.ais index afedc01..6db4726 100644 --- a/examples/sample.ais +++ b/examples/sample.ais @@ -4,4 +4,7 @@ !AIVDM,1,1,,B,100h00PP0@PHFV`Mg5gTH?vNPUIp,0*3B !AIVDM,1,1,,B,13eaJF0P00Qd388Eew6aagvH85Ip,0*45 !AIVDM,1,1,,A,14eGrSPP00ncMJTO5C6aBwvP2D0?,0*7A -!AIVDM,1,1,,A,15MrVH0000KH<:V:NtBLoqFP2H9:,0*2F \ No newline at end of file +!AIVDM,1,1,,A,15MrVH0000KH<:V:NtBLoqFP2H9:,0*2F +# Lines with a leading `#` are ignored +# Also invalid lines or invalid messages are ignored +IamNotAnAisMessage1111 \ No newline at end of file diff --git a/pyais/__init__.py b/pyais/__init__.py index 6756f3a..5e5fd7f 100644 --- a/pyais/__init__.py +++ b/pyais/__init__.py @@ -1,15 +1,20 @@ -from pyais.messages import NMEAMessage +from pyais.messages import NMEAMessage, ANY_MESSAGE from pyais.stream import TCPStream, FileReaderStream, IterMessages -from pyais.encode import encode_dict, encode_msg +from pyais.encode import encode_dict, encode_msg, ais_to_nmea_0183 +from pyais.decode import decode __license__ = 'MIT' -__version__ = '1.7.0' +__version__ = '2.0.0' +__author__ = 'Leon Morten Richter' __all__ = ( 'encode_dict', 'encode_msg', + 'ais_to_nmea_0183', 'NMEAMessage', + 'ANY_MESSAGE', 'TCPStream', 'IterMessages', - 'FileReaderStream' + 'FileReaderStream', + 'decode', ) diff --git a/pyais/stream.py b/pyais/stream.py index 6f2b2a3..5fbe4fd 100644 --- a/pyais/stream.py +++ b/pyais/stream.py @@ -1,7 +1,8 @@ +import typing from abc import ABC, abstractmethod from socket import AF_INET, SOCK_DGRAM, SOCK_STREAM, socket from typing import ( - Any, BinaryIO, Generator, Generic, Iterable, List, Optional, TypeVar, cast + BinaryIO, Generator, Generic, Iterable, List, Optional, TypeVar, cast ) from pyais.exceptions import InvalidNMEAMessageException @@ -22,6 +23,75 @@ def should_parse(byte_str: bytes) -> bool: return len(byte_str) > 0 and byte_str[0] in (DOLLAR_SIGN, EXCLAMATION_POINT) and byte_str.count(b",") == 6 +class NMEASorter: + + def __init__(self, messages: typing.Iterable[bytes]): + self._queue = FixedSizeDict(10000) + self.unordered = messages + + def __iter__(self) -> Generator[bytes, None, None]: + for msg in self.unordered: + # decode nmea header + self._split_nmea_header(msg) + if not self.seq_id: + yield msg + try: + complete = self._update_queue(msg) + if complete is not None: + yield from complete + + except KeyError: + # place item a correct pos and then store the list + self._add_to_queue(msg) + + def _split_nmea_header(self, msg: bytes) -> None: + """ + Read the important parts of a NMEA header + """ + parts: List[bytes] = msg.split(b',') + self.seq_id: int = int(parts[3]) if parts[3] else 0 + self.fragment_offset: int = int(parts[2]) - 1 + self.fragment_count: int = int(parts[1]) + + def _yield_complete(self) -> Optional[List[bytes]]: + """ + Check if the message is complete and return it + """ + # get all messages for the current sequence number + queue: List[bytes] = self._queue[self.seq_id][0:self.fragment_count] + if all(queue[0: self.fragment_count]): + # if all required messages are received yield them and free their space + del self._queue[self.seq_id] + return queue[0: self.fragment_count] + return None + + def _add_to_queue(self, msg: bytes) -> None: + """ + Append a new nmea message to queue + """ + # MAX frag offset for any AIS NMEA is 9 + msg_queue: List[Optional[bytes]] = ([None, ] * 9) + try: + # place the message at its correct position + msg_queue[self.fragment_offset] = msg + except IndexError: + # message is invalid clear it + del self._queue[self.seq_id] + self._queue[self.seq_id] = msg_queue + + def _update_queue(self, msg: bytes) -> Optional[Iterable[bytes]]: + """ + Update an existing message queue that is not complete yet. + Return a list of fully assembled messages if all required messages for a given sequence number are received. + """ + msg_queue: List[bytes] = self._queue[self.seq_id] + msg_queue[self.fragment_offset] = msg + complete: Optional[List[bytes]] = self._yield_complete() + if complete: + yield from complete + return None + + class AssembleMessages(ABC): """ Base class that assembles multiline messages. @@ -47,7 +117,8 @@ def __next__(self) -> NMEAMessage: def _assemble_messages(self) -> Generator[NMEAMessage, None, None]: queue: List[NMEAMessage] = [] - for line in self._iter_messages(): + messages = self._iter_messages() + for line in NMEASorter(messages): # Be gentle and just skip invalid messages try: @@ -170,86 +241,6 @@ def read(self) -> Generator[bytes, None, None]: yield from self.iterable -class OutOfOrderByteStream(Stream[F], ABC): - """ - Handles multipart NMEA that are delivered out of order. - - This class is not attached to a datasource by default. - You need to subclass it and override _get_messages(). - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: - # create a fixed sized message queue - self._queue = FixedSizeDict(10000) - super().__init__(*args, **kwargs) - - def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: - del self._queue - super().__exit__(exc_type, exc_val, exc_tb) - - def _split_nmea_header(self, msg: bytes) -> None: - """ - Read the important parts of a NMEA header - """ - parts: List[bytes] = msg.split(b',') - self.seq_id: int = int(parts[3]) if parts[3] else 0 - self.fragment_offset: int = int(parts[2]) - 1 - self.fragment_count: int = int(parts[1]) - - def _yield_complete(self) -> Optional[List[bytes]]: - """ - Check if the message is complete and return it - """ - # get all messages for the current sequence number - queue: List[bytes] = self._queue[self.seq_id][0:self.fragment_count] - if all(queue[0: self.fragment_count]): - # if all required messages are received yield them and free their space - del self._queue[self.seq_id] - return queue[0: self.fragment_count] - return None - - def _add_to_queue(self, msg: bytes) -> None: - """ - Append a new nmea message to queue - """ - # MAX frag offset for any AIS NMEA is 9 - msg_queue: List[Optional[bytes]] = ([None, ] * 9) - try: - # place the message at its correct position - msg_queue[self.fragment_offset] = msg - except IndexError: - # message is invalid clear it - del self._queue[self.seq_id] - self._queue[self.seq_id] = msg_queue - - def _update_queue(self, msg: bytes) -> Optional[Iterable[bytes]]: - """ - Update an existing message queue that is not complete yet. - Return a list of fully assembled messages if all required messages for a given sequence number are received. - """ - msg_queue: List[bytes] = self._queue[self.seq_id] - msg_queue[self.fragment_offset] = msg - complete: Optional[List[bytes]] = self._yield_complete() - if complete: - yield from complete - return None - - def _iter_messages(self) -> Generator[bytes, None, None]: - for msg in self.read(): - # decode nmea header - self._split_nmea_header(msg) - if not self.seq_id: - yield msg - try: - complete = self._update_queue(msg) - if complete is not None: - yield from complete - - except KeyError: - # place item a correct pos and then store the list - self._add_to_queue(msg) - - class SocketStream(Stream[socket]): BUF_SIZE = 4096 @@ -272,7 +263,7 @@ def read(self) -> Generator[bytes, None, None]: partial = lines[-1] -class UDPStream(OutOfOrderByteStream[socket], SocketStream): +class UDPStream(SocketStream): def __init__(self, host: str, port: int) -> None: sock: socket = socket(AF_INET, SOCK_DGRAM) diff --git a/tests/test_file_stream.py b/tests/test_file_stream.py index ebbae19..bb60a29 100644 --- a/tests/test_file_stream.py +++ b/tests/test_file_stream.py @@ -12,7 +12,7 @@ def test_reader(self): with FileReaderStream(self.FILENAME) as stream: messages = [msg for msg in stream] - assert len(messages) == 7 + self.assertEqual(len(messages), 7) for msg in messages: assert type(msg) == NMEAMessage assert msg.is_valid From 5e74df0332a5a02c2ad54c13574bb1be82867412 Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sun, 6 Feb 2022 13:44:40 +0100 Subject: [PATCH 07/18] Fix streams --- README.md | 10 ++-- examples/tcp_stream.py | 18 ++++-- examples/udp_stream.py | 15 +++-- pyais/__init__.py | 4 +- pyais/main.py | 8 +-- pyais/stream.py | 118 ++++++++++++++++---------------------- tests/mock_sender.py | 39 +++++++++++++ tests/test_file_stream.py | 113 +++++++++++++++++++++++++++++++++++- tests/test_tcp_stream.py | 8 +-- tests/test_udp_stream.py | 102 +------------------------------- 10 files changed, 241 insertions(+), 194 deletions(-) create mode 100644 tests/mock_sender.py diff --git a/README.md b/README.md index ae7c747..8e360fd 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,10 @@ for msg in FileReaderStream(filename): It is possible to directly convert messages into JSON. ```python -from pyais import TCPStream +from pyais import TCPConnection -for msg in TCPStream('ais.exploratorium.edu'): - json_data = msg.decode().to_json() +for msg in TCPConnection('ais.exploratorium.edu'): + json_data = msg.decode().to_json() ``` You can also parse a single message encoded as bytes or from a string: @@ -83,12 +83,12 @@ Another common use case is the reception of messages via UDP. This lib comes wit that. This stream class also handles out-of-order delivery of messages, which can occur when using UDP. ```py -from pyais.stream import UDPStream +from pyais.stream import UDPReceiver host = "127.0.0.1" port = 55555 -for msg in UDPStream(host, port): +for msg in UDPReceiver(host, port): msg.decode() # do something with it diff --git a/examples/tcp_stream.py b/examples/tcp_stream.py index bf5feb3..ff5c0dc 100644 --- a/examples/tcp_stream.py +++ b/examples/tcp_stream.py @@ -1,10 +1,16 @@ -from pyais.stream import TCPStream +""" +The following example shows how to decode AIS messages from a TCP socket. -url = 'ais.exploratorium.edu' -port = 80 +Pro-Tip: You can start a simple server that loops over some messages in order to test +this file. Just open a new terminal instance and run: `python tests/mock_server.py` +""" +from pyais.stream import TCPConnection -for msg in TCPStream(url, port=80): +url = '127.0.0.1' +port = 12346 + +for msg in TCPConnection(url, port=port): decoded_message = msg.decode() - ais_content = decoded_message.content + ais_content = decoded_message print(ais_content) - # Do something with the ais message + # Do something with the AIS message diff --git a/examples/udp_stream.py b/examples/udp_stream.py index e04b887..65fb8f2 100644 --- a/examples/udp_stream.py +++ b/examples/udp_stream.py @@ -1,8 +1,15 @@ -from pyais.stream import UDPStream +""" +This example shows how to list for incoming UDP packets. + +Pro-Tip: Start the UDP mock sender in a new terminal instance: `python tests/mock_sender.py` + +Afterwards, run this file, and you should receive some looped AIS messages. +""" +from pyais.stream import UDPReceiver host = "127.0.0.1" -port = 55555 +port = 12346 -for msg in UDPStream(host, port): - msg.decode() +for msg in UDPReceiver(host, port): + print(msg.decode()) # do something with it diff --git a/pyais/__init__.py b/pyais/__init__.py index 5e5fd7f..d73ad6e 100644 --- a/pyais/__init__.py +++ b/pyais/__init__.py @@ -1,5 +1,5 @@ from pyais.messages import NMEAMessage, ANY_MESSAGE -from pyais.stream import TCPStream, FileReaderStream, IterMessages +from pyais.stream import TCPConnection, FileReaderStream, IterMessages from pyais.encode import encode_dict, encode_msg, ais_to_nmea_0183 from pyais.decode import decode @@ -13,7 +13,7 @@ 'ais_to_nmea_0183', 'NMEAMessage', 'ANY_MESSAGE', - 'TCPStream', + 'TCPConnection', 'IterMessages', 'FileReaderStream', 'decode', diff --git a/pyais/main.py b/pyais/main.py index 0931911..167a3dc 100644 --- a/pyais/main.py +++ b/pyais/main.py @@ -2,7 +2,7 @@ import sys from typing import List, Tuple, Type, Any, Union -from pyais.stream import ByteStream, TCPStream, UDPStream, BinaryIOStream +from pyais.stream import ByteStream, TCPConnection, UDPReceiver, BinaryIOStream SOCKET_OPTIONS: Tuple[str, str] = ('udp', 'tcp') @@ -88,11 +88,11 @@ def print_error(*args: Any, **kwargs: Any) -> None: def decode_from_socket(args: argparse.Namespace) -> int: """Connect a socket and start decoding.""" t: str = args.type - stream_cls: Type[Union[UDPStream, TCPStream]] + stream_cls: Type[Union[UDPReceiver, TCPConnection]] if t == "udp": - stream_cls = UDPStream + stream_cls = UDPReceiver elif t == "tcp": - stream_cls = TCPStream + stream_cls = TCPConnection else: raise ValueError("args.type must be either TCP or UDP.") diff --git a/pyais/stream.py b/pyais/stream.py index 5fbe4fd..557938e 100644 --- a/pyais/stream.py +++ b/pyais/stream.py @@ -2,12 +2,11 @@ from abc import ABC, abstractmethod from socket import AF_INET, SOCK_DGRAM, SOCK_STREAM, socket from typing import ( - BinaryIO, Generator, Generic, Iterable, List, Optional, TypeVar, cast + BinaryIO, Generator, Generic, Iterable, List, TypeVar, cast ) from pyais.exceptions import InvalidNMEAMessageException from pyais.messages import NMEAMessage -from pyais.util import FixedSizeDict F = TypeVar("F", BinaryIO, socket, None) DOLLAR_SIGN = ord("$") @@ -26,70 +25,49 @@ def should_parse(byte_str: bytes) -> bool: class NMEASorter: def __init__(self, messages: typing.Iterable[bytes]): - self._queue = FixedSizeDict(10000) self.unordered = messages def __iter__(self) -> Generator[bytes, None, None]: + buffer: typing.Dict[typing.Tuple[int, bytes], typing.List[typing.Optional[bytes]]] = {} + for msg in self.unordered: # decode nmea header - self._split_nmea_header(msg) - if not self.seq_id: + + parts = msg.split(b',') + if len(parts) < 5: + raise ValueError("Too few message parts") + + frag_cnt = int(parts[1]) + frag_num = int(parts[2]) - 1 + seq_id = int(parts[3]) if parts[3] else 0 + channel = parts[4] + + if frag_cnt > 20: + raise ValueError("Frag count is too large") + + if frag_num >= frag_cnt: + raise ValueError("Fragment number greater than Fragment count") + + if frag_cnt == 1: + # A sentence with a fragment count of 1 is complete in itself yield msg - try: - complete = self._update_queue(msg) - if complete is not None: - yield from complete + continue - except KeyError: - # place item a correct pos and then store the list - self._add_to_queue(msg) + # seq_id and channel make a unique stream + slot = (seq_id, channel) - def _split_nmea_header(self, msg: bytes) -> None: - """ - Read the important parts of a NMEA header - """ - parts: List[bytes] = msg.split(b',') - self.seq_id: int = int(parts[3]) if parts[3] else 0 - self.fragment_offset: int = int(parts[2]) - 1 - self.fragment_count: int = int(parts[1]) + if slot not in buffer: + buffer[slot] = [None, ] * frag_cnt - def _yield_complete(self) -> Optional[List[bytes]]: - """ - Check if the message is complete and return it - """ - # get all messages for the current sequence number - queue: List[bytes] = self._queue[self.seq_id][0:self.fragment_count] - if all(queue[0: self.fragment_count]): - # if all required messages are received yield them and free their space - del self._queue[self.seq_id] - return queue[0: self.fragment_count] - return None + buffer[slot][frag_num] = msg + msg_parts = buffer[slot][0:frag_cnt] + if all([m is not None for m in msg_parts]): + yield from msg_parts # type: ignore + del buffer[slot] - def _add_to_queue(self, msg: bytes) -> None: - """ - Append a new nmea message to queue - """ - # MAX frag offset for any AIS NMEA is 9 - msg_queue: List[Optional[bytes]] = ([None, ] * 9) - try: - # place the message at its correct position - msg_queue[self.fragment_offset] = msg - except IndexError: - # message is invalid clear it - del self._queue[self.seq_id] - self._queue[self.seq_id] = msg_queue - - def _update_queue(self, msg: bytes) -> Optional[Iterable[bytes]]: - """ - Update an existing message queue that is not complete yet. - Return a list of fully assembled messages if all required messages for a given sequence number are received. - """ - msg_queue: List[bytes] = self._queue[self.seq_id] - msg_queue[self.fragment_offset] = msg - complete: Optional[List[bytes]] = self._yield_complete() - if complete: - yield from complete - return None + # yield all remaining messages that were not fully decoded + for msg_parts in buffer.values(): + yield from filter(lambda x: x is not None, msg_parts) # type: ignore class AssembleMessages(ABC): @@ -119,25 +97,21 @@ def _assemble_messages(self) -> Generator[NMEAMessage, None, None]: messages = self._iter_messages() for line in NMEASorter(messages): - - # Be gentle and just skip invalid messages try: msg: NMEAMessage = NMEAMessage(line) except InvalidNMEAMessageException: + # Be gentle and just skip invalid messages continue if msg.is_single: yield msg - - # Assemble multiline messages - elif msg.is_multi: + else: + # Assemble multiline messages queue.append(msg) if msg.fragment_number == msg.message_fragments: yield msg.assemble_from_iterable(queue) queue.clear() - else: - raise ValueError("Messages are out of order!") @abstractmethod def _iter_messages(self) -> Generator[bytes, None, None]: @@ -244,10 +218,14 @@ def read(self) -> Generator[bytes, None, None]: class SocketStream(Stream[socket]): BUF_SIZE = 4096 + def recv(self) -> bytes: + return b"" + def read(self) -> Generator[bytes, None, None]: partial: bytes = b'' while True: - body = self._fobj.recv(self.BUF_SIZE) + body = self.recv() + # Server closed connection if not body: return None @@ -263,20 +241,26 @@ def read(self) -> Generator[bytes, None, None]: partial = lines[-1] -class UDPStream(SocketStream): +class UDPReceiver(SocketStream): def __init__(self, host: str, port: int) -> None: sock: socket = socket(AF_INET, SOCK_DGRAM) sock.bind((host, port)) super().__init__(sock) + def recv(self) -> bytes: + return self._fobj.recvfrom(self.BUF_SIZE)[0] + -class TCPStream(SocketStream): +class TCPConnection(SocketStream): """ - NMEA0183 stream via socket. Refer to + Read AIS data from a remote TCP server https://en.wikipedia.org/wiki/NMEA_0183 """ + def recv(self) -> bytes: + return self._fobj.recv(self.BUF_SIZE) + def __init__(self, host: str, port: int = 80) -> None: sock: socket = socket(AF_INET, SOCK_STREAM) try: diff --git a/tests/mock_sender.py b/tests/mock_sender.py new file mode 100644 index 0000000..1c31c08 --- /dev/null +++ b/tests/mock_sender.py @@ -0,0 +1,39 @@ +import socket +import time + +MESSAGES = [ + b"!AIVDM,1,1,,B,133S0:0P00PCsJ:MECBR0gv:0D8N,0*7F", + b"!AIVDM,1,1,,A,4h2=a@Quho;O306WMpMIKqc9ww000OkfS@MMI5004R60<0B,0*31", + b"!AIVDM,1,1,,A,13PT8SDV0@2c,0*7D", + b"!AIVDM,1,1,,A,133ma5P0000Cj9lMG484pbN60D4?wvPl6=,0*38", + b"!AIVDM,1,1,,A,15NJIs0P0?JeI0RGBjbCCwv:282W,0*2E", + b"!AIVDM,1,1,,A,15MwGC2pdQOv:282b,0*0C", +] + + +def udp_mock_server(host, port) -> None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + while True: + while True: + print(f"Sending {len(MESSAGES)} messages all at once.") + # send all at once and then close + for msg in MESSAGES: + sock.sendto(msg + b"\r\n", (host, port)) + + time.sleep(2) + finally: + sock.close() + + +if __name__ == '__main__': + host = "127.0.0.1" + port = 12346 + print(f"Starting Mock UDP server on {host}:{port}") + udp_mock_server(host, port) diff --git a/tests/test_file_stream.py b/tests/test_file_stream.py index bb60a29..5f7ccaf 100644 --- a/tests/test_file_stream.py +++ b/tests/test_file_stream.py @@ -1,12 +1,121 @@ import pathlib import unittest from unittest.case import skip -from pyais.stream import FileReaderStream, should_parse + from pyais.messages import NMEAMessage +from pyais.stream import FileReaderStream, should_parse, NMEASorter class TestFileReaderStream(unittest.TestCase): - FILENAME = "tests/ais_test_messages" + FILENAME = str(pathlib.Path(__file__).parent.joinpath("ais_test_messages").absolute()) + + def test_nmea_sorter_sorted(self): + msgs = [ + b"!AIVDM,1,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23", + b"!AIVDM,1,1,,A,133sVfPP00PD>hRMDH@jNOvN20S8,0*7F", + b"!AIVDM,1,1,,B,100h00PP0@PHFV`Mg5gTH?vNPUIp,0*3B", + b"!AIVDM,2,1,1,A,55?MbV02;H;sAp:;R2APP08:c,0*0E", + b"!BSVDM,1,1,,A,15Mj23`PB`o=Of>KjvnJg8PT0L2R,0*7E", + b"!SAVDM,1,1,,B,35Mj2p001qo@5tVKLBWmIDJT01:@,0*33", + b"!AIVDM,1,1,,A,B5NWV1P0hRMDH@jNOvN20S8,0*7F", + b"!AIVDM,1,1,,B,100h00PP0@PHFV`Mg5gTH?vNPUIp,0*3B", + b"!SAVDM,2,1,4,A,55Mub7P00001L@;SO7TI8DDltqB222222222220O0000067<0620@jhQDTVG,0*43", + b"!AIVDM,2,1,1,A,55?MbV02;H;sAp:;R2APP08:c,0*0E", + b"!BSVDM,1,1,,A,15Mj23`PB`o=Of>KjvnJg8PT0L2R,0*7E", + b"!SAVDM,2,2,4,A,30H88888880,2*49", + b"!SAVDM,1,1,,B,35Mj2p001qo@5tVKLBWmIDJT01:@,0*33", + b"!AIVDM,1,1,,A,B5NWV1P0hRMDH@jNOvN20S8,0*7F", + b"!AIVDM,1,1,,B,100h00PP0@PHFV`Mg5gTH?vNPUIp,0*3B", + b"!AIVDM,2,1,1,A,55?MbV02;H;sAp:;R2APP08:c,0*0E", + b"!BSVDM,1,1,,A,15Mj23`PB`o=Of>KjvnJg8PT0L2R,0*7E", + b'!SAVDM,2,1,4,A,55Mub7P00001L@;SO7TI8DDltqB222222222220O0000067<0620@jhQDTVG,0*43', + b'!SAVDM,2,2,4,A,30H88888880,2*49', + b'!SAVDM,1,1,,B,35Mj2p001qo@5tVKLBWmIDJT01:@,0*33', + b'!AIVDM,1,1,,A,B5NWV1P0B6U30A2hCDhD`888,0*4D", + b'!SAVDM,2,2,4,A,30H88888880,2*49', + b"!AIVDM,2,2,1,A,88888888880,2*25", + b"!AIVDM,4,4,1,A,88888888880,2*25", + b"!AIVDM,4,3,1,A,88888888880,2*25", + b"!AIVDM,4,2,1,A,88888888880,2*25", + b"!AIVDM,4,1,1,A,88888888880,2*25", + ] + + expected = [ + b"!AIVDM,2,1,9,B,53nFBv01SJB6U30A2hCDhD`888,0*4D", + b"!AIVDM,2,2,8,A,88888888880,2*2C", + b'!SAVDM,2,1,4,A,55Mub7P00001L@;SO7TI8DDltqB222222222220O0000067<0620@jhQDTVG,0*43', + b'!SAVDM,2,2,4,A,30H88888880,2*49', + b"!AIVDM,2,1,1,A,55?MbV02;H;s02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*0F', + b'!AIVDM,9,2,2,A,F@V@00000000000,2*3D', + b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', + b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', + ] + expected = [ + b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', + b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', + b'!AIVDM,9,2,2,A,F@V@00000000000,2*3D', + ] + + actual = list(NMEASorter(msgs)) + self.assertEqual(expected, actual) + + def test_nmea_sort_invalid_frag_cnt(self): + msgs = [b"!AIVDM,21,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23", ] + with self.assertRaises(ValueError): + list(NMEASorter(msgs)) def test_reader(self): with FileReaderStream(self.FILENAME) as stream: diff --git a/tests/test_tcp_stream.py b/tests/test_tcp_stream.py index 84af110..e69d3aa 100644 --- a/tests/test_tcp_stream.py +++ b/tests/test_tcp_stream.py @@ -3,7 +3,7 @@ from tests.utils.skip import is_linux import threading import unittest -from pyais.stream import TCPStream +from pyais.stream import TCPConnection MESSAGES = [ b"!AIVDM,1,1,,B,133S0:0P00PCsJ:MECBR0gv:0D8N,0*7F", @@ -46,11 +46,11 @@ def _spawn_test_server(self): self.server_thread.start() def test_default_buf_size(self): - self.assertEqual(TCPStream.BUF_SIZE, 4096) + self.assertEqual(TCPConnection.BUF_SIZE, 4096) def test_invalid_endpoint(self): with self.assertRaises(ConnectionRefusedError): - TCPStream("0.0.0.0", 55555) + TCPConnection("0.0.0.0", 55555) @unittest.skipIf(not is_linux(), "Skipping because Signal is not available on non unix systems!") @unittest.skipIf(True, "Skip for now, because there is a Threading problem") @@ -59,7 +59,7 @@ def test_tcp_stream(self): with time_limit(2): self._spawn_test_server() - with TCPStream("0.0.0.0", 55555) as stream: + with TCPConnection("0.0.0.0", 55555) as stream: for i, msg in enumerate(stream): assert msg.decode() # make sure all messages were received diff --git a/tests/test_udp_stream.py b/tests/test_udp_stream.py index 7541817..bbeb584 100644 --- a/tests/test_udp_stream.py +++ b/tests/test_udp_stream.py @@ -1,10 +1,9 @@ import socket import threading import time -import typing import unittest -from pyais.stream import OutOfOrderByteStream, UDPStream +from pyais.stream import UDPReceiver from pyais.util import FixedSizeDict from tests.utils.skip import is_linux from tests.utils.timeout import time_limit @@ -34,20 +33,6 @@ def send(self): self.sock.close() -class TestOutOfOrderByteStream(OutOfOrderByteStream): - """ - Subclass OutOfOrderByteStream to test itÄs message assembly logic without real sockets - """ - - def __init__(self, iterable: typing.Iterable[bytes]) -> None: - # just accept some messages which then will be used as input - self.iterable: typing.Iterable[bytes] = iterable - super().__init__(None) - - def read(self) -> typing.Generator[bytes, None, None]: - yield from (msg for msg in self.iterable) - - class TestOutOfOrder(unittest.TestCase): def _spawn_test_server(self): self.server = MockUDPServer('127.0.0.1', 9999) @@ -82,7 +67,7 @@ def test_stream(self): host = "127.0.0.1" port = 9999 counter = 0 - with UDPStream(host, port) as stream: + with UDPReceiver(host, port) as stream: for msg in stream: assert msg.decode() counter += 1 @@ -91,86 +76,3 @@ def test_stream(self): break self.server_thread.join() - - def test_out_of_order(self): - messages = [ - b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', - b'!AIVDM,2,1,9,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*0F', - b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', - b'!AIVDM,2,2,9,A,F@V@00000000000,2*3D', - ] - counter = 0 - for msg in TestOutOfOrderByteStream(messages): - msg.decode() - counter += 1 - assert counter == 2 - - def test_out_fo_order_in_order(self): - # in order messages do not cause problems - messages = [ - b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', - b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', - b'!AIVDM,2,1,9,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*0F', - b'!AIVDM,2,2,9,A,F@V@00000000000,2*3D', - ] - counter = 0 - for msg in TestOutOfOrderByteStream(messages): - msg.decode() - counter += 1 - assert counter == 2 - - def test_split_nmea_header_method(self): - stream = TestOutOfOrderByteStream([]) - msg = b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07' - stream._split_nmea_header(msg) - assert stream.seq_id == 1 - assert stream.fragment_offset == 0 - assert stream.fragment_count == 2 - - # sequence id could be large - msg = b'!AIVDM,2,1,145859,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07' - stream._split_nmea_header(msg) - assert stream.seq_id == 145859 - - def test_index_error(self): - messages = [ - b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*0F', - b'!AIVDM,2,9,2,A,F@V@00000000000,2*3D', - b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', - b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', - ] - counter = 0 - for msg in TestOutOfOrderByteStream(messages): - msg.decode() - counter += 1 - - # only one message was yielded - assert counter == 1 - - def test_delete_after_yield(self): - messages = [ - b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', - b"!AIVDM,2,1,7,A,543ri001fIOiEa4<0010u84@4000000000000016;hD854o506SRBkk0FAEP,0*07", - b'!AIVDM,2,1,9,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*0F', - b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', - b"!AIVDM,2,2,7,A,00000000000,2*23", - b'!AIVDM,2,2,9,A,F@V@00000000000,2*3D', - ] - stream = TestOutOfOrderByteStream(messages) - iter_steam = iter(stream) - - # assure that messages are deleted after they are yielded - assert next(iter_steam).message_id == 1 - assert len(stream._queue) == 2 - assert next(iter_steam).message_id == 7 - assert len(stream._queue) == 1 - - def test_three(self): - messages = [ - b"!AIVDM,3,1,5,A,36KVnDh02wawaHPDA8T8h6tT8000t=AV=maD7?>BWiKIE@TR<2QfvaAF1ST4H31B,0*35", - b"!AIVDM,3,2,5,A,8IBP:UFW Date: Sun, 6 Feb 2022 15:43:02 +0100 Subject: [PATCH 08/18] Updates documentation --- CHANGELOG.txt | 9 + README.md | 86 +- docs/conversion.rst | 22 - docs/examples/encode.rst | 21 +- docs/examples/file.rst | 8 +- docs/examples/single.rst | 74 +- docs/examples/sockets.rst | 22 +- docs/messages.rst | 1541 ++++++++++++++++++++++++- docs/usage.rst | 1 - examples/decode.py | 45 + pyais/__init__.py | 2 +- pyais/decode.py | 4 +- pyais/messages.py | 51 + pyais/stream.py | 51 +- pyais/util.py | 26 - tests/{test_ais.py => test_decode.py} | 66 ++ tests/test_file_stream.py | 3 +- tests/test_udp_stream.py | 21 - tests/utils/generate_msg_interface.py | 16 + 19 files changed, 1828 insertions(+), 241 deletions(-) delete mode 100644 docs/conversion.rst create mode 100644 examples/decode.py rename tests/{test_ais.py => test_decode.py} (90%) create mode 100644 tests/utils/generate_msg_interface.py diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 38e9c26..e2d102b 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,15 @@ ==================== pyais CHANGELOG ==================== +------------------------------------------------------------------------------- + Version 2.0.0-alpha 6 Feb 2022 +------------------------------------------------------------------------------- + +* WARNING: The v2 release will introduce breaking changes +* Introduces the possibility to encode messages +* decoding has been rewritten and implements an iterative decoding approach + + ------------------------------------------------------------------------------- Version 1.6.2 2 May 2021 ------------------------------------------------------------------------------- diff --git a/README.md b/README.md index 8e360fd..943c8fc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ TCP/UDP sockets. You can find the full documentation on [readthedocs](https://pyais.readthedocs.io/en/latest/). +| :exclamation: The whole project was been partially rewritten. So expect breaking changes when upgrading from v1 to v2. You can install a preview with `pip install pyais==2.0.0-alpha` | +|---------------------------------------------------------------------------------------------------------------------------------------------------------| + # Acknowledgements ![Jetbrains Logo](./docs/jetbrains_logo.svg) @@ -40,58 +43,77 @@ $ pip install pyais # Usage -Using this module is easy. If you want to parse a file, that contains AIS messages, just copy the following code and -replace `filename` with your desired filename. - -```python -from pyais import FileReaderStream +U Decode a single part AIS message using `decode()`:: -filename = "sample.ais" +```py +from pyais import decode -for msg in FileReaderStream(filename): - decoded_message = msg.decode() - ais_content = decoded_message.content +decoded = decode(b"!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") +print(decoded) ``` -It is possible to directly convert messages into JSON. +The `decode()` functions accepts a list of arguments: One argument for every part of a multipart message:: + +```py +from pyais import decode -```python -from pyais import TCPConnection +parts = [ + b"!AIVDM,2,1,4,A,55O0W7`00001L@gCWGA2uItLth@DqtL5@F22220j1h742t0Ht0000000,0*08", + b"!AIVDM,2,2,4,A,000000000000000,2*20", +] -for msg in TCPConnection('ais.exploratorium.edu'): - json_data = msg.decode().to_json() +# Decode a multipart message using decode +decoded = decode(*parts) +print(decoded) ``` -You can also parse a single message encoded as bytes or from a string: +Also the `decode()` function accepts either strings or bytes:: -```python -from pyais import NMEAMessage, decode_msg +```py +from pyais import decode -message = NMEAMessage(b"!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C") -message = NMEAMessage.from_string("!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C") +decoded_b = decode(b"!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") +decoded_s = decode("!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") +assert decoded_b == decoded_s +``` -# or newer +Decode the message into a dictionary:: -msg = decode_msg("!AIVDM,1,1,,A,403Ovl@000Htt,0*05") +as_dict = decoded.asdict() +print(as_dict) ``` -See the example folder for more examples. - -Another common use case is the reception of messages via UDP. This lib comes with an `UDPStream` class that enables just -that. This stream class also handles out-of-order delivery of messages, which can occur when using UDP. +Read a file:: ```py -from pyais.stream import UDPReceiver +from pyais.stream import FileReaderStream + +filename = "sample.ais" -host = "127.0.0.1" -port = 55555 +for msg in FileReaderStream(filename): + decoded = msg.decode() + print(decoded) +``` -for msg in UDPReceiver(host, port): - msg.decode() - # do something with it +Decode a stream of messages (e.g. a list or generator):: +```py +from pyais import IterMessages + +fake_stream = [ + b"!AIVDM,1,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23", + b"!AIVDM,1,1,,A,133sVfPP00PD>hRMDH@jNOvN20S8,0*7F", + b"!AIVDM,1,1,,B,100h00PP0@PHFV`Mg5gTH?vNPUIp,0*3B", + b"!AIVDM,1,1,,B,13eaJF0P00Qd388Eew6aagvH85Ip,0*45", + b"!AIVDM,1,1,,A,14eGrSPP00ncMJTO5C6aBwvP2D0?,0*7A", + b"!AIVDM,1,1,,A,15MrVH0000KH<:V:NtBLoqFP2H9:,0*2F", +] +for msg in IterMessages(fake_stream): + print(msg.decode()) ``` ## Encode diff --git a/docs/conversion.rst b/docs/conversion.rst deleted file mode 100644 index 572dd21..0000000 --- a/docs/conversion.rst +++ /dev/null @@ -1,22 +0,0 @@ -############ -Conversion -############ - -The following fields are directly converted to floats without **ANY** conversion: - -1. Turn -2. Speed (speed over ground) -3. Longitude -4. Latitude -5. Course (Course over ground) -6. `to_bow`, `to_stern`, `to_port`, `to_starboard` -7. `ne_lon`, `ne_lat`, `sw_lon`, `sw_lat` - -All of these values are native ``floats``. This means that you need to convert the value into the format of choice. - -A common use case is to convert the values into strings, with fixed sized precision:: - - content = decode_msg("!AIVDO,1,1,,,B>qc:003wk?8mP=18D3Q3wgTiT;T,0*13") - print(content["speed"]) #=> 102.30000000000001 - print(format(content["speed"], ".1f")) #=> 102.3 - print(f"{content['speed'] :.6f}") #=> 102.300000 diff --git a/docs/examples/encode.rst b/docs/examples/encode.rst index 5e4e466..9ab322e 100644 --- a/docs/examples/encode.rst +++ b/docs/examples/encode.rst @@ -2,9 +2,24 @@ Encode AIS messages ############################# -It is also possible to encode messages using pyais. -Currently, this library supports creating NMEA formatted AIS type messages from type 1 to type 10. Support for other types -is planned. +The full specification of the AIVDM/AIVDO protocol is out of the scope of this example. +For a good overview of the AIVDM/AIVDO Sentence Layer please refer to this project: https://gpsd.gitlab.io/gpsd/AIVDM.html#_aivdmaivdo_sentence_layer + +But you should keep the following things in mind: + +- AIS messages are part of a two layer protocol +- the outer layer is the NMEA 0183 data exchange format +- the actual AIS message is part of the NMEA 0183’s 82-character payload +- because some AIS messages are larger than 82 characters they need to be split across several fragments +- there are 27 different types of AIS messages which differ in terms of fields + +Now to the actual encoding of messages: It is possible to encode a dictionary of values into an AIS message. +To do so, you need some values that you want to encode. The keys need to match the interface of the actual message. +You can call `.fields()` on any message class, to get glimpse on the available fields for each message type. +Unknown keys in the dict are simply omitted by pyais. Most keys have default values and do not need to +be passed explicitly. Only the keys `type` and `mmsi` are always required + +For the following example, let's assume that we want to create a type 1 AIS message. Examples ---------- diff --git a/docs/examples/file.rst b/docs/examples/file.rst index 92af907..a4386cb 100644 --- a/docs/examples/file.rst +++ b/docs/examples/file.rst @@ -6,17 +6,15 @@ Reading and parsing files Examples -------- -Parse a file:: +The following example shows how to read and parse AIS messages from a file:: from pyais.stream import FileReaderStream filename = "sample.ais" for msg in FileReaderStream(filename): - decoded_message = msg.decode() - ais_content = decoded_message.content - # Do something with the ais message - + decoded = msg.decode() + print(decoded) Please note, that by default the following lines are ignored: diff --git a/docs/examples/single.rst b/docs/examples/single.rst index 45eca95..4d9a34c 100644 --- a/docs/examples/single.rst +++ b/docs/examples/single.rst @@ -14,41 +14,65 @@ References Examples -------- -The newest version of Pyais introduced a more convinenient method to decode messages: `decode_msg`:: +Decode a single part AIS message using `decode()`:: - from pyais import decode_msg - decode_msg(b"!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C") - # => {'type': 1, 'repeat': 0, 'mmsi': '366053209', 'status': , 'turn': 0, 'speed': 0.0, 'accuracy': 0, 'lon': -122.34161833333333, 'lat': 37.80211833333333, 'course': 219.3, 'heading': 1, 'second': 59, 'maneuver': , 'raim': 0, 'radio': 2281} + from pyais import decode + decoded = decode(b"!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") + print(decoded) - # or - decode_msg("!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C") - # => {'type': 1, 'repeat': 0, 'mmsi': '366053209', 'status': , 'turn': 0, 'speed': 0.0, 'accuracy': 0, 'lon': -122.34161833333333, 'lat': 37.80211833333333, 'course': 219.3, 'heading': 1, 'second': 59, 'maneuver': , 'raim': 0, 'radio': 2281} +The `decode()` functions accepts a list of arguments: One argument for every part of a multipart message:: + from pyais import decode - # or decode a multiline message - decode_msg( - b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', - b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', - ) - # => {'type': 5, 'repeat': 0, 'mmsi': '210035000', 'ais_version': 0, 'imo': 9514755, 'callsign': '5BXT2', 'shipname': 'NORDIC HAMBURG', 'shiptype': , 'to_bow': 142, 'to_stern': 10, 'to_port': 11, 'to_starboard': 11, 'epfd': , 'month': 7, 'day': 20, 'hour': 5, 'minute': 0, 'draught': 7.1, 'destination': 'CTT-LAYBY', 'dte': 0} + parts = [ + b"!AIVDM,2,1,4,A,55O0W7`00001L@gCWGA2uItLth@DqtL5@F22220j1h742t0Ht0000000,0*08", + b"!AIVDM,2,2,4,A,000000000000000,2*20", + ] + # Decode a multipart message using decode + decoded = decode(*parts) + print(decoded) -.. warning:: - **Please note**, that `decode_msg` is only meant to decode a single message. - You **can not** use it to decode multiple messages at once. - But it supports multiline messages +Also the `decode()` function accepts either strings or bytes:: + decoded_b = decode(b"!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") + decoded_s = decode("!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") + assert decoded_b == decoded_s -Decode a single message (bytes):: +Decode the message into a dictionary:: - message = NMEAMessage(b"!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C") - print(message.decode()) - # => {'type': 1, 'repeat': 0, 'mmsi': '366053209', 'status': , 'turn': 0, 'speed': 0.0, 'accuracy': 0, 'lon': -122.34161833333333, 'lat': 37.80211833333333, 'course': 219.3, 'heading': 1, 'second': 59, 'maneuver': , 'raim': 0, 'radio': 2281} + decoded = decode(b"!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") + as_dict = decoded.asdict() + print(as_dict) +Decode the message into a serialized JSON string:: -Decode a single message (str):: + decoded = decode("!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") + json = decoded.to_json() + print(json) - message = NMEAMessage.from_string("!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C") - print(message.decode()) - # => {'type': 1, 'repeat': 0, 'mmsi': '366053209', 'status': , 'turn': 0, 'speed': 0.0, 'accuracy': 0, 'lon': -122.34161833333333, 'lat': 37.80211833333333, 'course': 219.3, 'heading': 1, 'second': 59, 'maneuver': , 'raim': 0, 'radio': 2281} +Read a file:: + + from pyais.stream import FileReaderStream + + filename = "sample.ais" + + for msg in FileReaderStream(filename): + decoded = msg.decode() + print(decoded) + +Decode a stream of messages (e.g. a list or generator):: + + from pyais import IterMessages + + fake_stream = [ + b"!AIVDM,1,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23", + b"!AIVDM,1,1,,A,133sVfPP00PD>hRMDH@jNOvN20S8,0*7F", + b"!AIVDM,1,1,,B,100h00PP0@PHFV`Mg5gTH?vNPUIp,0*3B", + b"!AIVDM,1,1,,B,13eaJF0P00Qd388Eew6aagvH85Ip,0*45", + b"!AIVDM,1,1,,A,14eGrSPP00ncMJTO5C6aBwvP2D0?,0*7A", + b"!AIVDM,1,1,,A,15MrVH0000KH<:V:NtBLoqFP2H9:,0*2F", + ] + for msg in IterMessages(fake_stream): + print(msg.decode()) diff --git a/docs/examples/sockets.rst b/docs/examples/sockets.rst index f264aeb..bf1f1a8 100644 --- a/docs/examples/sockets.rst +++ b/docs/examples/sockets.rst @@ -8,27 +8,27 @@ Examples Connect to a TCP socket:: - from pyais.stream import TCPStream + from pyais.stream import TCPConnection - url = 'ais.exploratorium.edu' - port = 80 + url = '127.0.0.1' + port = 12346 - for msg in TCPStream(url, port=80): + for msg in TCPConnection(url, port=port): decoded_message = msg.decode() - ais_content = decoded_message.content + ais_content = decoded_message print(ais_content) - # Do something with the ais message + # Do something with the AIS message -Connect to a UDP socket:: +Open to a UDP socket:: - from pyais.stream import UDPStream + from pyais.stream import UDPReceiver host = "127.0.0.1" - port = 55555 + port = 12346 - for msg in UDPStream(host, port): - msg.decode() + for msg in UDPReceiver(host, port): + print(msg.decode()) # do something with it The UDP stream handles out of order delivery of messages. By default it keeps the last up to 10.000 messages in memory to search for multiline messages. diff --git a/docs/messages.rst b/docs/messages.rst index d42aec4..5d1907a 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -16,18 +16,13 @@ If you want to decode the binary payload of message type 6, you firstly would ha dac (Designated Area Code) and the fid (Functional ID). Dependening of their values, you would know, how to interpret the payload. There are a lot of different application-specific messages, which are more or less standardized. -Therefore ``pyais`` does not even try to decode the payload. Instead, you can access the raw payload as a bit-string or a bitarray.:: +Therefore ``pyais`` does not even try to decode the payload. Instead, you can access the raw payload :: # Parse of message of type 8 msg = NMEAMessage(b"!AIVDM,1,1,,A,85Mwp`1Kf3aCnsNvBWLi=wQuNhA5t43N`5nCuI=p Payload as :bitarray: -Fields are also subscribable:: - - msg = NMEAMessage(b"!AIVDM,1,1,,A,15Mj23P000G?q7fK>g:o7@1:0L3S,0*1B") - - msg['ais_id'] == msg.ais_id - msg['raw'] == msg.raw - # etc. .. - Every message can be transformed into a dictionary:: msg = NMEAMessage(b"!AIVDM,1,1,,A,15Mj23P000G?q7fK>g:o7@1:0L3S,0*1B") @@ -82,47 +69,1519 @@ Every message can be transformed into a dictionary:: Multiline messages can be created as follows:: msg_1_part_0 = b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07' - msg_1_part_1 = b'!AIVDM,2,2,1,A,F@V@00000000000,2*35' + msg_1_part_1 = b'!AIVDM,2,2,1,A,F@V@00000000000,2*35' - assert NMEAMessage.assemble_from_iterable( - messages=[ - NMEAMessage(msg_1_part_0), - NMEAMessage(msg_1_part_1) - ] - ).decode() + decoded = NMEAMessage.assemble_from_iterable(msg_1_part_0, msg_1_part_1).decode() -In order to decode a NMEA message, it is first transformed into a `AISMessage`. See the documentation below for details:: +You can decode the message by calling `.decode()`. Depending on the Message type a message of Type 1 to 27 is returned. - msg = NMEAMessage(b"!AIVDM,1,1,,A,15Mj23P000G?q7fK>g:o7@1:0L3S,0*1B") - ais = msg.decode() -Sometimes, you might want quick access to a serialized JSON representation of a `NMEAMessage`:: - NMEAMessage(b"!AIVDM,1,1,,A,15Mj23P000G?q7fK>g:o7@1:0L3S,0*1B").decode().to_json() +Message classes +---------------- +There are 27 different types of AIS messages. Each message has different attributes and is encoded/decoded +differently depending of the AIS standard. But, there are some things that all messages do have in common: +When decoding: + - if the message payload has fewer bits than would be needed to decode every field, + the remaining fields are set to `None` -AISMessage ----------------- +When encoding: + - you should use `MessageType1.create(mmsi=123, ...)` for every message, as it sets default values + for missing attributes. + - `MessageType1.create(...)` always needs **at least** the mmsi keyword + - if you create the instance directly e.g. `MessageType1(mmsi=1, ...)`, you need to provide + **every possible** attribute, otherwise a `TypeError` is raised + +MessageType1 + AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 1 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `status` + * type: + * bit-width: 4 + * default: 0 + * `turn` + * type: + * bit-width: 8 + * default: 0 + * `speed` + * type: + * bit-width: 10 + * default: 0 + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `lon` + * type: + * bit-width: 28 + * default: 0 + * `lat` + * type: + * bit-width: 27 + * default: 0 + * `course` + * type: + * bit-width: 12 + * default: 0 + * `heading` + * type: + * bit-width: 9 + * default: 0 + * `second` + * type: + * bit-width: 6 + * default: 0 + * `maneuver` + * type: + * bit-width: 2 + * default: 0 + * `spare` + * type: + * bit-width: 3 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `radio` + * type: + * bit-width: 19 + * default: 0 +MessageType1 + AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 1 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `status` + * type: + * bit-width: 4 + * default: 0 + * `turn` + * type: + * bit-width: 8 + * default: 0 + * `speed` + * type: + * bit-width: 10 + * default: 0 + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `lon` + * type: + * bit-width: 28 + * default: 0 + * `lat` + * type: + * bit-width: 27 + * default: 0 + * `course` + * type: + * bit-width: 12 + * default: 0 + * `heading` + * type: + * bit-width: 9 + * default: 0 + * `second` + * type: + * bit-width: 6 + * default: 0 + * `maneuver` + * type: + * bit-width: 2 + * default: 0 + * `spare` + * type: + * bit-width: 3 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `radio` + * type: + * bit-width: 19 + * default: 0 +MessageType2 + AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 1 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `status` + * type: + * bit-width: 4 + * default: 0 + * `turn` + * type: + * bit-width: 8 + * default: 0 + * `speed` + * type: + * bit-width: 10 + * default: 0 + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `lon` + * type: + * bit-width: 28 + * default: 0 + * `lat` + * type: + * bit-width: 27 + * default: 0 + * `course` + * type: + * bit-width: 12 + * default: 0 + * `heading` + * type: + * bit-width: 9 + * default: 0 + * `second` + * type: + * bit-width: 6 + * default: 0 + * `maneuver` + * type: + * bit-width: 2 + * default: 0 + * `spare` + * type: + * bit-width: 3 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `radio` + * type: + * bit-width: 19 + * default: 0 +MessageType3 + AIS Vessel position report using ITDMA (Incremental Time Division Multiple Access) + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_types_1_2_and_3_position_report_class_a + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 1 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `status` + * type: + * bit-width: 4 + * default: 0 + * `turn` + * type: + * bit-width: 8 + * default: 0 + * `speed` + * type: + * bit-width: 10 + * default: 0 + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `lon` + * type: + * bit-width: 28 + * default: 0 + * `lat` + * type: + * bit-width: 27 + * default: 0 + * `course` + * type: + * bit-width: 12 + * default: 0 + * `heading` + * type: + * bit-width: 9 + * default: 0 + * `second` + * type: + * bit-width: 6 + * default: 0 + * `maneuver` + * type: + * bit-width: 2 + * default: 0 + * `spare` + * type: + * bit-width: 3 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `radio` + * type: + * bit-width: 19 + * default: 0 +MessageType4 + AIS Vessel position report using SOTDMA (Self-Organizing Time Division Multiple Access) + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_4_base_station_report + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 4 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `year` + * type: + * bit-width: 14 + * default: 1970 + * `month` + * type: + * bit-width: 4 + * default: 1 + * `day` + * type: + * bit-width: 5 + * default: 1 + * `hour` + * type: + * bit-width: 5 + * default: 0 + * `minute` + * type: + * bit-width: 6 + * default: 0 + * `second` + * type: + * bit-width: 6 + * default: 0 + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `lon` + * type: + * bit-width: 28 + * default: 0 + * `lat` + * type: + * bit-width: 27 + * default: 0 + * `epfd` + * type: + * bit-width: 4 + * default: 0 + * `spare` + * type: + * bit-width: 10 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `radio` + * type: + * bit-width: 19 + * default: 0 +MessageType5 + Static and Voyage Related Data + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_5_static_and_voyage_related_data + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 5 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `ais_version` + * type: + * bit-width: 2 + * default: 0 + * `imo` + * type: + * bit-width: 30 + * default: 0 + * `callsign` + * type: + * bit-width: 42 + * default: + * `shipname` + * type: + * bit-width: 120 + * default: + * `ship_type` + * type: + * bit-width: 8 + * default: 0 + * `to_bow` + * type: + * bit-width: 9 + * default: 0 + * `to_stern` + * type: + * bit-width: 9 + * default: 0 + * `to_port` + * type: + * bit-width: 6 + * default: 0 + * `to_starboard` + * type: + * bit-width: 6 + * default: 0 + * `epfd` + * type: + * bit-width: 4 + * default: 0 + * `month` + * type: + * bit-width: 4 + * default: 0 + * `day` + * type: + * bit-width: 5 + * default: 0 + * `hour` + * type: + * bit-width: 5 + * default: 0 + * `minute` + * type: + * bit-width: 6 + * default: 0 + * `draught` + * type: + * bit-width: 8 + * default: 0 + * `destination` + * type: + * bit-width: 120 + * default: + * `dte` + * type: + * bit-width: 1 + * default: 0 + * `spare` + * type: + * bit-width: 1 + * default: 0 +MessageType6 + Binary Addresses Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_4_base_station_report + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 6 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `seqno` + * type: + * bit-width: 2 + * default: 0 + * `dest_mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `retransmit` + * type: + * bit-width: 1 + * default: False + * `spare` + * type: + * bit-width: 1 + * default: 0 + * `dac` + * type: + * bit-width: 10 + * default: 0 + * `fid` + * type: + * bit-width: 6 + * default: 0 + * `data` + * type: + * bit-width: 920 + * default: 0 +MessageType7 + Binary Acknowledge + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_7_binary_acknowledge + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 7 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare` + * type: + * bit-width: 2 + * default: 0 + * `mmsi1` + * type: (, ) + * bit-width: 30 + * default: 0 + * `mmsiseq1` + * type: (, ) + * bit-width: 2 + * default: 0 + * `mmsi2` + * type: (, ) + * bit-width: 30 + * default: 0 + * `mmsiseq2` + * type: (, ) + * bit-width: 2 + * default: 0 + * `mmsi3` + * type: (, ) + * bit-width: 30 + * default: 0 + * `mmsiseq3` + * type: (, ) + * bit-width: 2 + * default: 0 + * `mmsi4` + * type: (, ) + * bit-width: 30 + * default: 0 + * `mmsiseq4` + * type: (, ) + * bit-width: 2 + * default: 0 +MessageType8 + Binary Acknowledge + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_8_binary_broadcast_message + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 8 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare` + * type: + * bit-width: 2 + * default: 0 + * `dac` + * type: + * bit-width: 10 + * default: 0 + * `fid` + * type: + * bit-width: 6 + * default: 0 + * `data` + * type: + * bit-width: 952 + * default: 0 +MessageType9 + Standard SAR Aircraft Position Report + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_9_standard_sar_aircraft_position_report + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 9 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `alt` + * type: + * bit-width: 12 + * default: 0 + * `speed` + * type: + * bit-width: 10 + * default: 0 + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `lon` + * type: + * bit-width: 28 + * default: 0 + * `lat` + * type: + * bit-width: 27 + * default: 0 + * `course` + * type: + * bit-width: 12 + * default: 0 + * `second` + * type: + * bit-width: 6 + * default: 0 + * `reserved` + * type: + * bit-width: 8 + * default: 0 + * `dte` + * type: + * bit-width: 1 + * default: 0 + * `spare` + * type: + * bit-width: 3 + * default: 0 + * `assigned` + * type: + * bit-width: 1 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `radio` + * type: + * bit-width: 20 + * default: 0 +MessageType10 + UTC/Date Inquiry + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_10_utc_date_inquiry + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 10 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare_1` + * type: + * bit-width: 2 + * default: 0 + * `dest_mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare_2` + * type: + * bit-width: 2 + * default: 0 +MessageType11 + UTC/Date Response + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_11_utc_date_response + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 4 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `year` + * type: + * bit-width: 14 + * default: 1970 + * `month` + * type: + * bit-width: 4 + * default: 1 + * `day` + * type: + * bit-width: 5 + * default: 1 + * `hour` + * type: + * bit-width: 5 + * default: 0 + * `minute` + * type: + * bit-width: 6 + * default: 0 + * `second` + * type: + * bit-width: 6 + * default: 0 + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `lon` + * type: + * bit-width: 28 + * default: 0 + * `lat` + * type: + * bit-width: 27 + * default: 0 + * `epfd` + * type: + * bit-width: 4 + * default: 0 + * `spare` + * type: + * bit-width: 10 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `radio` + * type: + * bit-width: 19 + * default: 0 +MessageType12 + Addressed Safety-Related Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_12_addressed_safety_related_message + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 12 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `seqno` + * type: + * bit-width: 2 + * default: 0 + * `dest_mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `retransmit` + * type: + * bit-width: 1 + * default: 0 + * `spare` + * type: + * bit-width: 1 + * default: 0 + * `text` + * type: + * bit-width: 936 + * default: +MessageType13 + Identical to type 7 + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 7 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare` + * type: + * bit-width: 2 + * default: 0 + * `mmsi1` + * type: (, ) + * bit-width: 30 + * default: 0 + * `mmsiseq1` + * type: (, ) + * bit-width: 2 + * default: 0 + * `mmsi2` + * type: (, ) + * bit-width: 30 + * default: 0 + * `mmsiseq2` + * type: (, ) + * bit-width: 2 + * default: 0 + * `mmsi3` + * type: (, ) + * bit-width: 30 + * default: 0 + * `mmsiseq3` + * type: (, ) + * bit-width: 2 + * default: 0 + * `mmsi4` + * type: (, ) + * bit-width: 30 + * default: 0 + * `mmsiseq4` + * type: (, ) + * bit-width: 2 + * default: 0 +MessageType14 + Safety-Related Broadcast Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_14_safety_related_broadcast_message + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 14 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare` + * type: + * bit-width: 2 + * default: 0 + * `text` + * type: + * bit-width: 968 + * default: +MessageType15 + Interrogation + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_15_interrogation + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 15 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare_1` + * type: + * bit-width: 2 + * default: 0 + * `mmsi1` + * type: (, ) + * bit-width: 30 + * default: 0 + * `type1_1` + * type: + * bit-width: 6 + * default: 0 + * `offset1_1` + * type: + * bit-width: 12 + * default: 0 + * `spare_2` + * type: + * bit-width: 2 + * default: 0 + * `type1_2` + * type: + * bit-width: 6 + * default: 0 + * `offset1_2` + * type: + * bit-width: 12 + * default: 0 + * `spare_3` + * type: + * bit-width: 2 + * default: 0 + * `mmsi2` + * type: (, ) + * bit-width: 30 + * default: 0 + * `type2_1` + * type: + * bit-width: 6 + * default: 0 + * `offset2_1` + * type: + * bit-width: 12 + * default: 0 + * `spare_4` + * type: + * bit-width: 2 + * default: 0 +MessageType16 + Assignment Mode Command + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_16_assignment_mode_command + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 16 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare` + * type: + * bit-width: 2 + * default: 0 + * `mmsi1` + * type: (, ) + * bit-width: 30 + * default: 0 + * `offset1` + * type: + * bit-width: 12 + * default: 0 + * `increment1` + * type: + * bit-width: 10 + * default: 0 + * `mmsi2` + * type: (, ) + * bit-width: 30 + * default: 0 + * `offset2` + * type: + * bit-width: 12 + * default: 0 + * `increment2` + * type: + * bit-width: 10 + * default: 0 +MessageType17 + DGNSS Broadcast Binary Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_17_dgnss_broadcast_binary_message + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 17 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare_1` + * type: + * bit-width: 2 + * default: 0 + * `lon` + * type: + * bit-width: 18 + * default: 0 + * `lat` + * type: + * bit-width: 17 + * default: 0 + * `spare_2` + * type: + * bit-width: 5 + * default: 0 + * `data` + * type: + * bit-width: 736 + * default: 0 +MessageType18 + Standard Class B CS Position Report + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_18_standard_class_b_cs_position_report + + + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 18 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `reserved` + * type: + * bit-width: 8 + * default: 0 + * `speed` + * type: + * bit-width: 10 + * default: 0 + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `lon` + * type: + * bit-width: 28 + * default: 0 + * `lat` + * type: + * bit-width: 27 + * default: 0 + * `course` + * type: + * bit-width: 12 + * default: 0 + * `heading` + * type: + * bit-width: 9 + * default: 0 + * `second` + * type: + * bit-width: 6 + * default: 0 + * `reserved_2` + * type: + * bit-width: 2 + * default: 0 + * `cs` + * type: + * bit-width: 1 + * default: 0 + * `display` + * type: + * bit-width: 1 + * default: 0 + * `dsc` + * type: + * bit-width: 1 + * default: 0 + * `band` + * type: + * bit-width: 1 + * default: 0 + * `msg22` + * type: + * bit-width: 1 + * default: 0 + * `assigned` + * type: + * bit-width: 1 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `radio` + * type: + * bit-width: 20 + * default: 0 +MessageType19 + Extended Class B CS Position Report + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_19_extended_class_b_cs_position_report -Every `AISMessage` message has the following interface: + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 19 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `reserved` + * type: + * bit-width: 8 + * default: 0 + * `speed` + * type: + * bit-width: 10 + * default: 0 + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `lon` + * type: + * bit-width: 28 + * default: 0 + * `lat` + * type: + * bit-width: 27 + * default: 0 + * `course` + * type: + * bit-width: 12 + * default: 0 + * `heading` + * type: + * bit-width: 9 + * default: 0 + * `second` + * type: + * bit-width: 6 + * default: 0 + * `regional` + * type: + * bit-width: 4 + * default: 0 + * `shipname` + * type: + * bit-width: 120 + * default: + * `ship_type` + * type: + * bit-width: 8 + * default: 0 + * `to_bow` + * type: + * bit-width: 9 + * default: 0 + * `to_stern` + * type: + * bit-width: 9 + * default: 0 + * `to_port` + * type: + * bit-width: 6 + * default: 0 + * `to_starboard` + * type: + * bit-width: 6 + * default: 0 + * `epfd` + * type: + * bit-width: 4 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `dte` + * type: + * bit-width: 1 + * default: 0 + * `assigned` + * type: + * bit-width: 1 + * default: 0 + * `spare` + * type: + * bit-width: 4 + * default: 0 +MessageType20 + Data Link Management Message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_20_data_link_management_message -Get the parent NMEA message:: + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 20 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare` + * type: + * bit-width: 2 + * default: 0 + * `offset1` + * type: + * bit-width: 12 + * default: 0 + * `number1` + * type: + * bit-width: 4 + * default: 0 + * `timeout1` + * type: + * bit-width: 3 + * default: 0 + * `increment1` + * type: + * bit-width: 11 + * default: 0 + * `offset2` + * type: + * bit-width: 12 + * default: 0 + * `number2` + * type: + * bit-width: 4 + * default: 0 + * `timeout2` + * type: + * bit-width: 3 + * default: 0 + * `increment2` + * type: + * bit-width: 11 + * default: 0 + * `offset3` + * type: + * bit-width: 12 + * default: 0 + * `number3` + * type: + * bit-width: 4 + * default: 0 + * `timeout3` + * type: + * bit-width: 3 + * default: 0 + * `increment3` + * type: + * bit-width: 11 + * default: 0 + * `offset4` + * type: + * bit-width: 12 + * default: 0 + * `number4` + * type: + * bit-width: 4 + * default: 0 + * `timeout4` + * type: + * bit-width: 3 + * default: 0 + * `increment4` + * type: + * bit-width: 11 + * default: 0 +MessageType21 + Aid-to-Navigation Report + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_21_aid_to_navigation_report - ais = AISMessage() - ais.nmea -Get message type:: + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 21 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `aid_type` + * type: + * bit-width: 5 + * default: 0 + * `shipname` + * type: + * bit-width: 120 + * default: + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `lon` + * type: + * bit-width: 28 + * default: 0 + * `lat` + * type: + * bit-width: 27 + * default: 0 + * `to_bow` + * type: + * bit-width: 9 + * default: 0 + * `to_stern` + * type: + * bit-width: 9 + * default: 0 + * `to_port` + * type: + * bit-width: 6 + * default: 0 + * `to_starboard` + * type: + * bit-width: 6 + * default: 0 + * `epfd` + * type: + * bit-width: 4 + * default: 0 + * `second` + * type: + * bit-width: 6 + * default: 0 + * `off_position` + * type: + * bit-width: 1 + * default: 0 + * `regional` + * type: + * bit-width: 8 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `virtual_aid` + * type: + * bit-width: 1 + * default: 0 + * `assigned` + * type: + * bit-width: 1 + * default: 0 + * `spare` + * type: + * bit-width: 1 + * default: 0 + * `name_ext` + * type: + * bit-width: 88 + * default: +MessageType23 + Group Assignment Command + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_23_group_assignment_command - ais = AISMessage() - ais.msg_type -Get content:: + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 23 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `spare_1` + * type: + * bit-width: 2 + * default: 0 + * `ne_lon` + * type: + * bit-width: 18 + * default: 0 + * `ne_lat` + * type: + * bit-width: 17 + * default: 0 + * `sw_lon` + * type: + * bit-width: 18 + * default: 0 + * `sw_lat` + * type: + * bit-width: 17 + * default: 0 + * `station_type` + * type: + * bit-width: 4 + * default: 0 + * `ship_type` + * type: + * bit-width: 8 + * default: 0 + * `spare_2` + * type: + * bit-width: 22 + * default: 0 + * `txrx` + * type: + * bit-width: 2 + * default: 0 + * `interval` + * type: + * bit-width: 4 + * default: 0 + * `quiet` + * type: + * bit-width: 4 + * default: 0 + * `spare_3` + * type: + * bit-width: 6 + * default: 0 +MessageType27 + Long Range AIS Broadcast message + Src: https://gpsd.gitlab.io/gpsd/AIVDM.html#_type_27_long_range_ais_broadcast_message - ais = AISMessage() - ais.content -`AISMessage.content` is a dictionary that holds all decoded fields. You can get all available fields -for every message through the `fields` attribute. All available fields are documented here: https://gpsd.gitlab.io/gpsd/AIVDM.html + Attributes: + * `msg_type` + * type: + * bit-width: 6 + * default: 27 + * `repeat` + * type: + * bit-width: 2 + * default: 0 + * `mmsi` + * type: (, ) + * bit-width: 30 + * default: None + * `accuracy` + * type: + * bit-width: 1 + * default: 0 + * `raim` + * type: + * bit-width: 1 + * default: 0 + * `status` + * type: + * bit-width: 4 + * default: 0 + * `lon` + * type: + * bit-width: 18 + * default: 0 + * `lat` + * type: + * bit-width: 17 + * default: 0 + * `speed` + * type: + * bit-width: 6 + * default: 0 + * `course` + * type: + * bit-width: 9 + * default: 0 + * `gnss` + * type: + * bit-width: 1 + * default: 0 + * `spare` + * type: + * bit-width: 1 + * default: 0 diff --git a/docs/usage.rst b/docs/usage.rst index 8076d55..9b46c22 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -9,4 +9,3 @@ Usage examples examples/single examples/file examples/sockets - conversion diff --git a/examples/decode.py b/examples/decode.py new file mode 100644 index 0000000..c0f25f0 --- /dev/null +++ b/examples/decode.py @@ -0,0 +1,45 @@ +from pyais import decode, IterMessages + +# Decode a single part using decode +decoded = decode(b"!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") +print(decoded) + +parts = [ + b"!AIVDM,2,1,4,A,55O0W7`00001L@gCWGA2uItLth@DqtL5@F22220j1h742t0Ht0000000,0*08", + b"!AIVDM,2,2,4,A,000000000000000,2*20", +] + +# Decode a multipart message using decode +decoded = decode(*parts) +print(decoded) + +# Decode a string +decoded = decode("!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") +print(decoded) + +# Decode to dict +decoded = decode("!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") +as_dict = decoded.asdict() +print(as_dict) + +# Decode to json +decoded = decode("!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") +json = decoded.to_json() +print(json) + +# It does not matter if you pass a string or bytes +decoded_b = decode(b"!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") +decoded_s = decode("!AIVDM,1,1,,B,15NG6V0P01G?cFhE`R2IU?wn28R>,0*05") +assert decoded_b == decoded_s + +# Lets say you have some kind of stream of messages. Than you can use `IterMessages` to decode the messages: +fake_stream = [ + b"!AIVDM,1,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23", + b"!AIVDM,1,1,,A,133sVfPP00PD>hRMDH@jNOvN20S8,0*7F", + b"!AIVDM,1,1,,B,100h00PP0@PHFV`Mg5gTH?vNPUIp,0*3B", + b"!AIVDM,1,1,,B,13eaJF0P00Qd388Eew6aagvH85Ip,0*45", + b"!AIVDM,1,1,,A,14eGrSPP00ncMJTO5C6aBwvP2D0?,0*7A", + b"!AIVDM,1,1,,A,15MrVH0000KH<:V:NtBLoqFP2H9:,0*2F", +] +for msg in IterMessages(fake_stream): + print(msg.decode()) diff --git a/pyais/__init__.py b/pyais/__init__.py index d73ad6e..4120d51 100644 --- a/pyais/__init__.py +++ b/pyais/__init__.py @@ -4,7 +4,7 @@ from pyais.decode import decode __license__ = 'MIT' -__version__ = '2.0.0' +__version__ = '2.0.0-alpha' __author__ = 'Leon Morten Richter' __all__ = ( diff --git a/pyais/decode.py b/pyais/decode.py index 762605c..f3534b6 100644 --- a/pyais/decode.py +++ b/pyais/decode.py @@ -1,7 +1,7 @@ import typing from pyais.exceptions import TooManyMessagesException, MissingMultipartMessageException -from pyais.messages import NMEAMessage, ANY_MESSAGE +from pyais.messages import NMEAMessage, ANY_MESSAGE, NMEASorter def _assemble_messages(*args: bytes) -> NMEAMessage: @@ -31,5 +31,5 @@ def _assemble_messages(*args: bytes) -> NMEAMessage: def decode(*args: typing.Union[str, bytes]) -> ANY_MESSAGE: parts = tuple(msg.encode('utf-8') if isinstance(msg, str) else msg for msg in args) - nmea = _assemble_messages(*parts) + nmea = _assemble_messages(*list(NMEASorter(parts))) return nmea.decode() diff --git a/pyais/messages.py b/pyais/messages.py index 0ebab89..9794da7 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -13,6 +13,57 @@ encode_ascii_6, from_bytes, int_to_bytes, from_bytes_signed, decode_bin_as_ascii6, get_int +class NMEASorter: + + def __init__(self, messages: typing.Iterable[bytes]): + self.unordered = messages + + def __iter__(self) -> typing.Generator[bytes, None, None]: + buffer: typing.Dict[typing.Tuple[int, bytes], typing.List[typing.Optional[bytes]]] = {} + + for msg in self.unordered: + # decode nmea header + + parts = msg.split(b',') + if len(parts) < 5: + raise InvalidNMEAMessageException("Too few message parts") + + try: + frag_cnt = int(parts[1]) + frag_num = int(parts[2]) - 1 + seq_id = int(parts[3]) if parts[3] else 0 + channel = parts[4] + except ValueError as e: + raise InvalidNMEAMessageException() from e + + if frag_cnt > 20: + raise InvalidNMEAMessageException("Frag count is too large") + + if frag_num >= frag_cnt: + raise InvalidNMEAMessageException("Fragment number greater than Fragment count") + + if frag_cnt == 1: + # A sentence with a fragment count of 1 is complete in itself + yield msg + continue + + # seq_id and channel make a unique stream + slot = (seq_id, channel) + + if slot not in buffer: + buffer[slot] = [None, ] * frag_cnt + + buffer[slot][frag_num] = msg + msg_parts = buffer[slot][0:frag_cnt] + if all([m is not None for m in msg_parts]): + yield from msg_parts # type: ignore + del buffer[slot] + + # yield all remaining messages that were not fully decoded + for msg_parts in buffer.values(): + yield from filter(lambda x: x is not None, msg_parts) # type: ignore + + def validate_message(msg: bytes) -> None: """ Validates a given message. diff --git a/pyais/stream.py b/pyais/stream.py index 557938e..f3e5f2e 100644 --- a/pyais/stream.py +++ b/pyais/stream.py @@ -1,4 +1,3 @@ -import typing from abc import ABC, abstractmethod from socket import AF_INET, SOCK_DGRAM, SOCK_STREAM, socket from typing import ( @@ -6,7 +5,7 @@ ) from pyais.exceptions import InvalidNMEAMessageException -from pyais.messages import NMEAMessage +from pyais.messages import NMEAMessage, NMEASorter F = TypeVar("F", BinaryIO, socket, None) DOLLAR_SIGN = ord("$") @@ -22,54 +21,6 @@ def should_parse(byte_str: bytes) -> bool: return len(byte_str) > 0 and byte_str[0] in (DOLLAR_SIGN, EXCLAMATION_POINT) and byte_str.count(b",") == 6 -class NMEASorter: - - def __init__(self, messages: typing.Iterable[bytes]): - self.unordered = messages - - def __iter__(self) -> Generator[bytes, None, None]: - buffer: typing.Dict[typing.Tuple[int, bytes], typing.List[typing.Optional[bytes]]] = {} - - for msg in self.unordered: - # decode nmea header - - parts = msg.split(b',') - if len(parts) < 5: - raise ValueError("Too few message parts") - - frag_cnt = int(parts[1]) - frag_num = int(parts[2]) - 1 - seq_id = int(parts[3]) if parts[3] else 0 - channel = parts[4] - - if frag_cnt > 20: - raise ValueError("Frag count is too large") - - if frag_num >= frag_cnt: - raise ValueError("Fragment number greater than Fragment count") - - if frag_cnt == 1: - # A sentence with a fragment count of 1 is complete in itself - yield msg - continue - - # seq_id and channel make a unique stream - slot = (seq_id, channel) - - if slot not in buffer: - buffer[slot] = [None, ] * frag_cnt - - buffer[slot][frag_num] = msg - msg_parts = buffer[slot][0:frag_cnt] - if all([m is not None for m in msg_parts]): - yield from msg_parts # type: ignore - del buffer[slot] - - # yield all remaining messages that were not fully decoded - for msg_parts in buffer.values(): - yield from filter(lambda x: x is not None, msg_parts) # type: ignore - - class AssembleMessages(ABC): """ Base class that assembles multiline messages. diff --git a/pyais/util.py b/pyais/util.py index 5494573..a72790f 100644 --- a/pyais/util.py +++ b/pyais/util.py @@ -125,32 +125,6 @@ def compute_checksum(msg: Union[str, bytes]) -> int: return reduce(xor, msg) -class FixedSizeDict(BaseDict): - """ - Fixed sized dictionary that only contains up to N keys. - """ - - def __init__(self, maxlen: int) -> None: - super().__init__() - self.maxlen: int = maxlen - - def __setitem__(self, k: Hashable, v: Any) -> None: - super().__setitem__(k, v) - # if the maximum number is reach delete the oldest n keys - if len(self) >= self.maxlen: - self._pop_oldest() - - def _pop_oldest(self) -> Any: - # instead of calling this method often, we delete a whole bunch of keys in one run - for _ in range(self._items_to_pop): - self.popitem(last=False) - - @property - def _items_to_pop(self) -> int: - # delete 1/5th of keys - return self.maxlen // 5 - - # https://gpsd.gitlab.io/gpsd/AIVDM.html#_aivdmaivdo_payload_armoring PAYLOAD_ARMOR = { 0: '0', 1: '1', 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: ':', diff --git a/tests/test_ais.py b/tests/test_decode.py similarity index 90% rename from tests/test_ais.py rename to tests/test_decode.py index bb35a92..d2b95cc 100644 --- a/tests/test_ais.py +++ b/tests/test_decode.py @@ -643,3 +643,69 @@ def test_nmea_decode_unknown_msg(self): nmea = NMEAMessage(b"!AIVDO,1,1,,,B>qc:003wk?8mP=18D3Q3wgTiT;T,0*13") nmea.ais_id = 28 nmea.decode() + + def test_decode_out_of_order(self): + parts = [ + b"!AIVDM,2,2,4,A,000000000000000,2*20", + b"!AIVDM,2,1,4,A,55O0W7`00001L@gCWGA2uItLth@DqtL5@F22220j1h742t0Ht0000000,0*08", + ] + + decoded = decode(*parts) + + assert decoded.asdict()['mmsi'] == '368060190' + print(decoded) + + def test_issue_46_a(self): + msg = b"!ARVDM,2,1,3,B,E>m1c1>9TV`9WW@97QUP0000000F@lEpmdceP00003b,0*5C" + decoded = NMEAMessage(msg).decode() + + self.assertEqual(decoded.msg_type, 21) + self.assertEqual(decoded.repeat, 0) + self.assertEqual(decoded.mmsi, '995126020') + self.assertEqual(decoded.aid_type, NavAid.ISOLATED_DANGER) + self.assertEqual(decoded.shipname, 'SIMPSON ROCK') + self.assertEqual(decoded.accuracy, True) + self.assertEqual(decoded.lon, 175.119987) + self.assertEqual(decoded.lat, -36.0075) + self.assertEqual(decoded.to_bow, 0) + self.assertEqual(decoded.to_stern, 0) + self.assertEqual(decoded.to_port, 0) + self.assertEqual(decoded.to_starboard, 0) + self.assertEqual(decoded.epfd, EpfdType.Surveyed) + self.assertEqual(decoded.second, 10) + + # The following fields are None + self.assertIsNone(decoded.off_position) + self.assertIsNone(decoded.regional) + self.assertIsNone(decoded.raim) + self.assertIsNone(decoded.virtual_aid) + self.assertIsNone(decoded.assigned) + self.assertIsNone(decoded.name_ext) + + def test_issue_46_b(self): + msg = b"!AIVDM,1,1,,B,E>lt;KLab21@1bb@I@@@@@@@@@@D8k2tnmvs000003v0@,2*52" + decoded = NMEAMessage(msg).decode() + + print(decoded) + self.assertEqual(decoded.msg_type, 21) + self.assertEqual(decoded.repeat, 0) + self.assertEqual(decoded.mmsi, '995036013') + self.assertEqual(decoded.aid_type, NavAid.STARBOARD_HAND_MARK) + self.assertEqual(decoded.shipname, 'STDB CUT 2') + self.assertEqual(decoded.accuracy, True) + self.assertEqual(decoded.lon, 115.691833) + self.assertEqual(decoded.lat, -32.004333) + self.assertEqual(decoded.to_bow, 0) + self.assertEqual(decoded.to_stern, 0) + self.assertEqual(decoded.to_port, 0) + self.assertEqual(decoded.to_starboard, 0) + self.assertEqual(decoded.epfd, EpfdType.Surveyed) + self.assertEqual(decoded.second, 60) + self.assertEqual(decoded.off_position, False) + self.assertEqual(decoded.regional, 4) + + # The following fields are None + self.assertIsNone(decoded.raim) + self.assertIsNone(decoded.virtual_aid) + self.assertIsNone(decoded.assigned) + self.assertIsNone(decoded.name_ext) diff --git a/tests/test_file_stream.py b/tests/test_file_stream.py index 5f7ccaf..3f51d13 100644 --- a/tests/test_file_stream.py +++ b/tests/test_file_stream.py @@ -2,6 +2,7 @@ import unittest from unittest.case import skip +from pyais.exceptions import InvalidNMEAMessageException from pyais.messages import NMEAMessage from pyais.stream import FileReaderStream, should_parse, NMEASorter @@ -114,7 +115,7 @@ def test_nmea_sort_index_error(self): def test_nmea_sort_invalid_frag_cnt(self): msgs = [b"!AIVDM,21,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23", ] - with self.assertRaises(ValueError): + with self.assertRaises(InvalidNMEAMessageException): list(NMEASorter(msgs)) def test_reader(self): diff --git a/tests/test_udp_stream.py b/tests/test_udp_stream.py index bbeb584..5356752 100644 --- a/tests/test_udp_stream.py +++ b/tests/test_udp_stream.py @@ -4,7 +4,6 @@ import unittest from pyais.stream import UDPReceiver -from pyais.util import FixedSizeDict from tests.utils.skip import is_linux from tests.utils.timeout import time_limit @@ -39,26 +38,6 @@ def _spawn_test_server(self): self.server_thread = threading.Thread(target=self.server.send) self.server_thread.start() - def test_fixed_sized_dict(self): - N = 10000 - queue = FixedSizeDict(N + 1) - for i in range(N): - queue[i] = i - - # no keys were delted - assert len(queue) == N - assert queue.popitem(last=False)[0] == 0 - assert queue.popitem(last=True)[0] == N - 1 - - # add another - queue[N + 1] = 35 - queue[N + 2] = 35 - queue[N + 3] = 35 - # now 1/5th of keys is delted - assert len(queue) == N - (N // 5) + 1 - # make sure only the oldest ones were deleted - assert queue.popitem(last=False)[0] == (N // 5) + 1 - @unittest.skipIf(not is_linux(), "Skipping because Signal is not available on non unix systems!") def test_stream(self): # Test the UDP stream with real data diff --git a/tests/utils/generate_msg_interface.py b/tests/utils/generate_msg_interface.py new file mode 100644 index 0000000..87d4dd6 --- /dev/null +++ b/tests/utils/generate_msg_interface.py @@ -0,0 +1,16 @@ +from pyais.messages import MSG_CLASS + +for typ, cls in MSG_CLASS.items(): + if not cls.fields(): + continue + print(cls.__name__, cls.__doc__) + print() + print("\tAttributes:") + for field in cls.fields(): + print("\t\t*", f"`{field.name}`") + if 'mmsi' in field.name: + print("\t\t\t*", "type:", f"({int}, {str})") + else: + print("\t\t\t*", "type:", field.metadata['d_type']) + print("\t\t\t*", "bit-width:", field.metadata['width']) + print("\t\t\t*", "default:", field.metadata['default']) From 9008f4cb616628a36742b2f0d6542f31f89676ba Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sat, 12 Feb 2022 15:59:07 +0100 Subject: [PATCH 09/18] Removes NMEASorter and adds a testcase for examples --- examples/file_stream.py | 6 +- pyais/decode.py | 6 +- pyais/messages.py | 129 ++++++-------------------------------- pyais/stream.py | 34 +++++++--- pyais/util.py | 16 +---- tests/test_examples.py | 20 ++++++ tests/test_file_stream.py | 38 ++++++----- tests/test_nmea.py | 30 +++------ 8 files changed, 104 insertions(+), 175 deletions(-) create mode 100644 tests/test_examples.py diff --git a/examples/file_stream.py b/examples/file_stream.py index ebe79d0..0e069b5 100644 --- a/examples/file_stream.py +++ b/examples/file_stream.py @@ -7,10 +7,12 @@ - invalid messages are skipped - invalid lines are skipped """ +import pathlib + from pyais.stream import FileReaderStream -filename = "sample.ais" +filename = pathlib.Path(__file__).parent.joinpath('sample.ais') -for msg in FileReaderStream(filename): +for msg in FileReaderStream(str(filename)): decoded = msg.decode() print(decoded) diff --git a/pyais/decode.py b/pyais/decode.py index f3534b6..d9d082f 100644 --- a/pyais/decode.py +++ b/pyais/decode.py @@ -1,7 +1,7 @@ import typing from pyais.exceptions import TooManyMessagesException, MissingMultipartMessageException -from pyais.messages import NMEAMessage, ANY_MESSAGE, NMEASorter +from pyais.messages import NMEAMessage, ANY_MESSAGE def _assemble_messages(*args: bytes) -> NMEAMessage: @@ -12,7 +12,7 @@ def _assemble_messages(*args: bytes) -> NMEAMessage: for msg in args: nmea = NMEAMessage(msg) temp.append(nmea) - frags.append(nmea.fragment_number) + frags.append(nmea.frag_num) frag_cnt = nmea.fragment_count # Make sure provided parts assemble a single (multiline message) @@ -31,5 +31,5 @@ def _assemble_messages(*args: bytes) -> NMEAMessage: def decode(*args: typing.Union[str, bytes]) -> ANY_MESSAGE: parts = tuple(msg.encode('utf-8') if isinstance(msg, str) else msg for msg in args) - nmea = _assemble_messages(*list(NMEASorter(parts))) + nmea = _assemble_messages(*parts) return nmea.decode() diff --git a/pyais/messages.py b/pyais/messages.py index 9794da7..c24c2cd 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -9,61 +9,10 @@ from pyais.constants import TalkerID, NavigationStatus, ManeuverIndicator, EpfdType, ShipType, NavAid, StationType, \ TransmitMode, StationIntervals from pyais.exceptions import InvalidNMEAMessageException, UnknownMessageException -from pyais.util import decode_into_bit_array, compute_checksum, deprecated, int_to_bin, str_to_bin, \ +from pyais.util import decode_into_bit_array, compute_checksum, int_to_bin, str_to_bin, \ encode_ascii_6, from_bytes, int_to_bytes, from_bytes_signed, decode_bin_as_ascii6, get_int -class NMEASorter: - - def __init__(self, messages: typing.Iterable[bytes]): - self.unordered = messages - - def __iter__(self) -> typing.Generator[bytes, None, None]: - buffer: typing.Dict[typing.Tuple[int, bytes], typing.List[typing.Optional[bytes]]] = {} - - for msg in self.unordered: - # decode nmea header - - parts = msg.split(b',') - if len(parts) < 5: - raise InvalidNMEAMessageException("Too few message parts") - - try: - frag_cnt = int(parts[1]) - frag_num = int(parts[2]) - 1 - seq_id = int(parts[3]) if parts[3] else 0 - channel = parts[4] - except ValueError as e: - raise InvalidNMEAMessageException() from e - - if frag_cnt > 20: - raise InvalidNMEAMessageException("Frag count is too large") - - if frag_num >= frag_cnt: - raise InvalidNMEAMessageException("Fragment number greater than Fragment count") - - if frag_cnt == 1: - # A sentence with a fragment count of 1 is complete in itself - yield msg - continue - - # seq_id and channel make a unique stream - slot = (seq_id, channel) - - if slot not in buffer: - buffer[slot] = [None, ] * frag_cnt - - buffer[slot][frag_num] = msg - msg_parts = buffer[slot][0:frag_cnt] - if all([m is not None for m in msg_parts]): - yield from msg_parts # type: ignore - del buffer[slot] - - # yield all remaining messages that were not fully decoded - for msg_parts in buffer.values(): - yield from filter(lambda x: x is not None, msg_parts) # type: ignore - - def validate_message(msg: bytes) -> None: """ Validates a given message. @@ -111,7 +60,7 @@ def validate_message(msg: bytes) -> None: try: sentence_num = int(values[1]) - if sentence_num > 9: + if sentence_num > 0xff: raise InvalidNMEAMessageException( "Number of sentences exceeds limit of 9 total sentences." ) @@ -123,7 +72,7 @@ def validate_message(msg: bytes) -> None: if values[2]: try: sentence_num = int(values[2]) - if sentence_num > 9: + if sentence_num > 0xff: raise InvalidNMEAMessageException( " Sentence number exceeds limit of 9 total sentences." ) @@ -135,7 +84,7 @@ def validate_message(msg: bytes) -> None: if values[3]: try: sentence_num = int(values[3]) - if sentence_num > 9: + if sentence_num > 0xff: raise InvalidNMEAMessageException( "Number of sequential message ID exceeds limit of 9 total sentences." ) @@ -150,14 +99,6 @@ def validate_message(msg: bytes) -> None: f"{msg.decode('utf-8')} has more than 82 characters of payload." ) - # Only encapsulated messages are currently supported - if values[0][0] != 0x21: - # https://en.wikipedia.org/wiki/Automatic_identification_system - raise InvalidNMEAMessageException( - "'NMEAMessage' only supports !AIVDM/!AIVDO encapsulated messages. " - f"These start with an '!', but got '{chr(values[0][0])}'" - ) - def bit_field(width: int, d_type: typing.Type[typing.Any], from_converter: typing.Optional[typing.Callable[[typing.Any], typing.Any]] = None, @@ -195,9 +136,9 @@ class NMEAMessage(object): 'raw', 'talker', 'type', - 'message_fragments', - 'fragment_number', - 'message_id', + 'frag_cnt', + 'frag_num', + 'seq_id', 'channel', 'payload', 'fill_bits', @@ -239,11 +180,11 @@ def __init__(self, raw: bytes) -> None: self.type: str = head[3:].decode('ascii') # Total number of fragments - self.message_fragments: int = int(message_fragments) + self.frag_cnt: int = int(message_fragments) # Current fragment index - self.fragment_number: int = int(fragment_number) + self.frag_num: int = int(fragment_number) # Optional message index for multiline messages - self.message_id: Optional[int] = int(message_id) if message_id else None + self.seq_id: Optional[int] = int(message_id) if message_id else None # Channel (A or B) self.channel: str = channel.decode('ascii') # Decoded message payload as byte string @@ -279,9 +220,9 @@ def asdict(self) -> Dict[str, Any]: 'raw': self.raw.decode('ascii'), # str 'talker': self.talker.value, # str 'type': self.type, # str - 'message_fragments': self.message_fragments, # int - 'fragment_number': self.fragment_number, # int - 'message_id': self.message_id, # None or int + 'frag_cnt': self.frag_cnt, # int + 'frag_num': self.frag_num, # int + 'seq_id': self.seq_id, # None or int 'channel': self.channel, # str 'payload': self.payload.decode('ascii'), # str 'fill_bits': self.fill_bits, # int @@ -311,7 +252,9 @@ def assemble_from_iterable(cls, messages: Sequence["NMEAMessage"]) -> "NMEAMessa data = b'' bit_array = bitarray() - for msg in messages: + for i, msg in enumerate(sorted(messages, key=lambda m: m.frag_num)): + if i > 0: + raw += b'\n' raw += msg.raw data += msg.payload bit_array += msg.bit_array @@ -327,7 +270,7 @@ def is_valid(self) -> bool: @property def is_single(self) -> bool: - return not self.message_id and self.fragment_number == self.message_fragments == 1 + return not self.seq_id and self.frag_num == self.frag_cnt == 1 @property def is_multi(self) -> bool: @@ -335,43 +278,7 @@ def is_multi(self) -> bool: @property def fragment_count(self) -> int: - return self.message_fragments - - @deprecated - def count(self) -> int: - """ - Only there fore legacy compatibility. - Was renamed to `message_fragments` - @return: message_fragments as int - """ - return self.message_fragments - - @deprecated - def index(self) -> int: - """ - Only there fore legacy compatibility. - Was renamed to `fragment_number` - @return: fragment_number as int - """ - return self.fragment_number - - @deprecated - def seq_id(self) -> Optional[int]: - """ - Only there fore legacy compatibility. - Was renamed to `message_id` - @return: message_id as int - """ - return self.message_id - - @deprecated - def data(self) -> bytes: - """ - Only there fore legacy compatibility. - Was renamed to `payload` - @return: payload as sequence of bytes - """ - return self.payload + return self.frag_cnt def decode(self) -> "ANY_MESSAGE": """ diff --git a/pyais/stream.py b/pyais/stream.py index f3e5f2e..166d419 100644 --- a/pyais/stream.py +++ b/pyais/stream.py @@ -1,3 +1,4 @@ +import typing from abc import ABC, abstractmethod from socket import AF_INET, SOCK_DGRAM, SOCK_STREAM, socket from typing import ( @@ -5,7 +6,7 @@ ) from pyais.exceptions import InvalidNMEAMessageException -from pyais.messages import NMEAMessage, NMEASorter +from pyais.messages import NMEAMessage F = TypeVar("F", BinaryIO, socket, None) DOLLAR_SIGN = ord("$") @@ -44,10 +45,11 @@ def __next__(self) -> NMEAMessage: return next(iter(self)) def _assemble_messages(self) -> Generator[NMEAMessage, None, None]: - queue: List[NMEAMessage] = [] + buffer: typing.Dict[typing.Tuple[int, str], typing.List[typing.Optional[NMEAMessage]]] = {} messages = self._iter_messages() - for line in NMEASorter(messages): + for line in messages: + try: msg: NMEAMessage = NMEAMessage(line) except InvalidNMEAMessageException: @@ -57,12 +59,26 @@ def _assemble_messages(self) -> Generator[NMEAMessage, None, None]: if msg.is_single: yield msg else: - # Assemble multiline messages - queue.append(msg) - - if msg.fragment_number == msg.message_fragments: - yield msg.assemble_from_iterable(queue) - queue.clear() + # Instead of None use -1 as a seq_id + seq_id = msg.seq_id + if seq_id is None: + seq_id = -1 + + # seq_id and channel make a unique stream + slot = (seq_id, msg.channel) + + if slot not in buffer: + # Create a new array in the buffer that has enough space for all fragments + buffer[slot] = [None, ] * max(msg.fragment_count, 0xff) + + buffer[slot][msg.frag_num - 1] = msg + msg_parts = buffer[slot][0:msg.fragment_count] + + # Check if all fragments are found + not_none_parts = [m for m in msg_parts if m is not None] + if len(not_none_parts) == msg.fragment_count: + yield NMEAMessage.assemble_from_iterable(not_none_parts) + del buffer[slot] @abstractmethod def _iter_messages(self) -> Generator[bytes, None, None]: diff --git a/pyais/util.py b/pyais/util.py index a72790f..846c89d 100644 --- a/pyais/util.py +++ b/pyais/util.py @@ -1,9 +1,8 @@ import typing -import warnings from collections import OrderedDict from functools import partial, reduce from operator import xor -from typing import Any, Generator, Hashable, TYPE_CHECKING, Callable, Union +from typing import Any, Generator, Hashable, TYPE_CHECKING, Union from bitarray import bitarray @@ -18,19 +17,6 @@ T = typing.TypeVar('T') -def deprecated(f: Callable[[Any], Any]) -> Callable[[Any], Any]: - @property # type: ignore - def wrapper(self: Any) -> Any: - warnings.simplefilter('always', DeprecationWarning) # turn off filter - warnings.warn(f"{f.__name__} is deprecated and will be removed soon.", - category=DeprecationWarning) - warnings.simplefilter('default', DeprecationWarning) # reset filter - - return f(self) - - return wrapper - - def decode_into_bit_array(data: bytes, fill_bits: int = 0) -> bitarray: """ Decodes a raw AIS message into a bitarray. diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..b7bc27c --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,20 @@ +import os +import pathlib +import subprocess +import unittest + + +class TestExamples(unittest.TestCase): + """ + Make sure that every example still runs - expect UDP and TCP examples as they require a socket + """ + + def test_run_every_file(self): + i = -1 + for i, file in enumerate(pathlib.Path(__file__).parent.parent.joinpath('examples').glob('*.py')): + if 'tcp' not in str(file) and 'udp' not in str(file): + env = os.environ + env['PYTHONPATH'] = f':{pathlib.Path(__file__).parent.parent.absolute()}' + assert subprocess.check_call(f'python3 {file}'.split(), env=env, shell=False) == 0 + + assert i == 7 diff --git a/tests/test_file_stream.py b/tests/test_file_stream.py index 3f51d13..e19d62d 100644 --- a/tests/test_file_stream.py +++ b/tests/test_file_stream.py @@ -2,9 +2,8 @@ import unittest from unittest.case import skip -from pyais.exceptions import InvalidNMEAMessageException from pyais.messages import NMEAMessage -from pyais.stream import FileReaderStream, should_parse, NMEASorter +from pyais.stream import FileReaderStream, should_parse, IterMessages class TestFileReaderStream(unittest.TestCase): @@ -25,8 +24,11 @@ def test_nmea_sorter_sorted(self): b"!SAVDM,2,1,4,A,55Mub7P00001L@;SO7TI8DDltqB222222222220O0000067<0620@jhQDTVG,0*43", b"!SAVDM,2,2,4,A,30H88888880,2*49", ] - sorter = NMEASorter(msgs) - output = list(sorter) + sorter = IterMessages(msgs) + output = [] + for msg in sorter: + output += msg.raw.splitlines() + self.assertEqual(output, msgs) def test_nmea_sorter_unsorted(self): @@ -44,8 +46,10 @@ def test_nmea_sorter_unsorted(self): b"!AIVDM,1,1,,A,B5NWV1P0hRMDH@jNOvN20S8,0*7F", @@ -94,8 +98,12 @@ def test_sort_with_different_msg_ids(self): b"!AIVDM,4,4,1,A,88888888880,2*25", ] - actual = list(NMEASorter(msgs)) - self.assertEqual(expected, actual) + sorter = IterMessages(msgs) + output = [] + for msg in sorter: + output += msg.raw.splitlines() + + self.assertEqual(expected, output) def test_nmea_sort_index_error(self): msgs = [ @@ -107,16 +115,18 @@ def test_nmea_sort_index_error(self): expected = [ b'!AIVDM,2,1,1,A,538CQ>02A;h?D9QC800pu8@T>0P4l9E8L0000017Ah:;;5r50Ahm5;C0,0*07', b'!AIVDM,2,2,1,A,F@V@00000000000,2*35', - b'!AIVDM,9,2,2,A,F@V@00000000000,2*3D', ] - actual = list(NMEASorter(msgs)) - self.assertEqual(expected, actual) + sorter = IterMessages(msgs) + output = [] + for msg in sorter: + output += msg.raw.splitlines() + + self.assertEqual(expected, output) def test_nmea_sort_invalid_frag_cnt(self): - msgs = [b"!AIVDM,21,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23", ] - with self.assertRaises(InvalidNMEAMessageException): - list(NMEASorter(msgs)) + msgs = [b"!AIVDM,256,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23", ] + self.assertEqual(len(list(IterMessages(msgs))), 0) def test_reader(self): with FileReaderStream(self.FILENAME) as stream: diff --git a/tests/test_nmea.py b/tests/test_nmea.py index 8a55ec0..bacc0fb 100644 --- a/tests/test_nmea.py +++ b/tests/test_nmea.py @@ -74,9 +74,9 @@ def test_attrs(self): nmea = NMEAMessage(msg) assert nmea.ais_id == 8 - assert nmea.message_fragments == 1 - assert nmea.fragment_number == 1 - assert nmea.message_id is None + assert nmea.frag_cnt == 1 + assert nmea.frag_num == 1 + assert nmea.seq_id is None assert nmea.channel == "A" assert nmea.payload == b"85Mwp`1Kf3aCnsNvBWLi=wQuNhA5t43N`5nCuI=pg:o7@1:0L3S,0*1B" msg = NMEAMessage(msg) @@ -140,9 +136,9 @@ def serializable(o: object): self.assertEqual("!AIVDM,1,1,,A,15Mj23P000G?q7fK>g:o7@1:0L3S,0*1B", actual["raw"]) self.assertEqual("AI", actual["talker"]) self.assertEqual("VDM", actual["type"]) - self.assertEqual(1, actual["message_fragments"]) - self.assertEqual(1, actual["fragment_number"]) - self.assertEqual(None, actual["message_id"]) + self.assertEqual(1, actual["frag_cnt"]) + self.assertEqual(1, actual["frag_num"]) + self.assertEqual(None, actual["seq_id"]) self.assertEqual("A", actual["channel"]) self.assertEqual("15Mj23P000G?q7fK>g:o7@1:0L3S", actual["payload"]) self.assertEqual(0, actual["fill_bits"]) @@ -155,9 +151,9 @@ def test_get_item(self): self.assertEqual(b"!AIVDM,1,1,,A,15Mj23P000G?q7fK>g:o7@1:0L3S,0*1B", msg["raw"]) self.assertEqual("AI", msg["talker"]) self.assertEqual("VDM", msg["type"]) - self.assertEqual(1, msg["message_fragments"]) - self.assertEqual(1, msg["fragment_number"]) - self.assertEqual(None, msg["message_id"]) + self.assertEqual(1, msg["frag_cnt"]) + self.assertEqual(1, msg["frag_num"]) + self.assertEqual(None, msg["seq_id"]) self.assertEqual("A", msg["channel"]) self.assertEqual(b"15Mj23P000G?q7fK>g:o7@1:0L3S", msg["payload"]) self.assertEqual(0, msg["fill_bits"]) @@ -177,11 +173,3 @@ def test_get_item_raises_type_error(self): with self.assertRaises(TypeError): _ = msg[1:3] - - def test_deprecated(self): - msg = NMEAMessage(b"!AIVDM,1,1,,A,15Mj23P000G?q7fK>g:o7@1:0L3S,0*1B") - - self.assertEqual(msg.count, msg.fragment_count) - self.assertEqual(msg.index, msg.fragment_number) - self.assertEqual(msg.seq_id, msg.message_id) - self.assertEqual(msg.data, msg.payload) From e04a967340f61dd95c30f7b3ca70eaec878cfe17 Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sat, 12 Feb 2022 16:08:05 +0100 Subject: [PATCH 10/18] Add clean target --- Makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Makefile b/Makefile index 8bdcba0..47a5acf 100644 --- a/Makefile +++ b/Makefile @@ -14,4 +14,11 @@ check-build: type-check: mypy ./pyais +clean: + rm -rf .mypy_cache + rm -rf build + rm -rf dist + rm coverage.xml + rm .coverage + test: run_tests flake type-check \ No newline at end of file From 63fe7fd14e1b3b6616f5c5c6ea613e7cc4ffdb28 Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sun, 20 Feb 2022 16:16:42 +0100 Subject: [PATCH 11/18] Implement @Inrixia suggestions --- pyais/messages.py | 32 +++++++++++-- tests/test_decode.py | 109 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/pyais/messages.py b/pyais/messages.py index c24c2cd..c6612a0 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -130,6 +130,9 @@ def bit_field(width: int, d_type: typing.Type[typing.Any], ) +ENUM_FIELDS = {'status', 'maneuver', 'epfd', 'ship_type', 'aid_type', 'station_type', 'txrx', 'interval'} + + class NMEAMessage(object): __slots__ = ( 'ais_id', @@ -230,6 +233,19 @@ def asdict(self) -> Dict[str, Any]: 'bit_array': self.bit_array.to01(), # str } + def decode_and_merge(self, enum_as_int: bool = False) -> Dict[str, Any]: + """ + Decodes the message and returns the result as a dict together with all attributes of + the original NMEA message. + @param enum_as_int: Set to True to treat IntEnums as pure integers + @return: A dictionary that holds all fields, defined in __slots__ + the decoded msg + """ + rlt = self.asdict() + del rlt['bit_array'] + decoded = self.decode() + rlt.update(decoded.asdict(enum_as_int)) + return rlt + def __eq__(self, other: object) -> bool: return all([getattr(self, attr) == getattr(other, attr) for attr in self.__slots__]) @@ -377,7 +393,9 @@ def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": # Iterate over the bits until the last bit of the bitarray or all fields are fully decoded for field in cls.fields(): + if end >= len(bit_arr): + # All fields that did not fit into the bit array are None kwargs[field.name] = None continue @@ -410,10 +428,16 @@ def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": return cls(**kwargs) # type:ignore - def asdict(self) -> typing.Dict[str, typing.Any]: - d = {} - for field in self.fields(): - d[field.name] = getattr(self, field.name) + def asdict(self, enum_as_int: bool = False) -> typing.Dict[str, typing.Any]: + """ + Convert the message to a dictionary. + @param enum_as_int: If set to True all Enum values will be returned as raw ints. + @return: The message as a dictionary. + """ + if enum_as_int: + d = {slt: int(getattr(self, slt)) if slt in ENUM_FIELDS else getattr(self, slt) for slt in self.__slots__} + else: + d = {slt: getattr(self, slt) for slt in self.__slots__} return d def to_json(self) -> str: diff --git a/tests/test_decode.py b/tests/test_decode.py index d2b95cc..0a0f929 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -653,7 +653,6 @@ def test_decode_out_of_order(self): decoded = decode(*parts) assert decoded.asdict()['mmsi'] == '368060190' - print(decoded) def test_issue_46_a(self): msg = b"!ARVDM,2,1,3,B,E>m1c1>9TV`9WW@97QUP0000000F@lEpmdceP00003b,0*5C" @@ -686,7 +685,6 @@ def test_issue_46_b(self): msg = b"!AIVDM,1,1,,B,E>lt;KLab21@1bb@I@@@@@@@@@@D8k2tnmvs000003v0@,2*52" decoded = NMEAMessage(msg).decode() - print(decoded) self.assertEqual(decoded.msg_type, 21) self.assertEqual(decoded.repeat, 0) self.assertEqual(decoded.mmsi, '995036013') @@ -709,3 +707,110 @@ def test_issue_46_b(self): self.assertIsNone(decoded.virtual_aid) self.assertIsNone(decoded.assigned) self.assertIsNone(decoded.name_ext) + + def test_to_dict_non_enum(self): + """Enum types do not use None if the fields are missing when partial decoding""" + msg = b"!AIVDM,1,1,,B,E>lt;KLab21@1bb@I@@@@@@@@@@D8k2tnmvs000003v0@,2*52" + decoded = NMEAMessage(msg).decode() + + d = decoded.asdict(enum_as_int=True) + self.assertEqual( + d, {'accuracy': True, + 'aid_type': 25, + 'assigned': None, + 'epfd': 7, + 'lat': -32.004333, + 'lon': 115.691833, + 'mmsi': '995036013', + 'msg_type': 21, + 'name_ext': None, + 'off_position': False, + 'raim': None, + 'regional': 4, + 'repeat': 0, + 'second': 60, + 'shipname': 'STDB CUT 2', + 'spare': None, + 'to_bow': 0, + 'to_port': 0, + 'to_starboard': 0, + 'to_stern': 0, + 'virtual_aid': None} + ) + + def test_decode_and_merge(self): + msg = b"!AIVDM,1,1,,B,E>lt;KLab21@1bb@I@@@@@@@@@@D8k2tnmvs000003v0@,2*52" + decoded = NMEAMessage(msg) + + d = decoded.decode_and_merge(enum_as_int=True) + + self.assertEqual( + d, {'accuracy': True, + 'aid_type': 25, + 'ais_id': 21, + 'assigned': None, + 'channel': 'B', + 'checksum': 82, + 'epfd': 7, + 'fill_bits': 2, + 'frag_cnt': 1, + 'frag_num': 1, + 'lat': -32.004333, + 'lon': 115.691833, + 'mmsi': '995036013', + 'msg_type': 21, + 'name_ext': None, + 'off_position': False, + 'payload': 'E>lt;KLab21@1bb@I@@@@@@@@@@D8k2tnmvs000003v0@', + 'raim': None, + 'raw': '!AIVDM,1,1,,B,E>lt;KLab21@1bb@I@@@@@@@@@@D8k2tnmvs000003v0@,2*52', + 'regional': 4, + 'repeat': 0, + 'second': 60, + 'seq_id': None, + 'shipname': 'STDB CUT 2', + 'spare': None, + 'talker': 'AI', + 'to_bow': 0, + 'to_port': 0, + 'to_starboard': 0, + 'to_stern': 0, + 'type': 'VDM', + 'virtual_aid': None} + ) + + d = decoded.decode_and_merge(enum_as_int=False) + self.assertEqual( + d, {'accuracy': True, + 'aid_type': NavAid.STARBOARD_HAND_MARK, + 'ais_id': 21, + 'assigned': None, + 'channel': 'B', + 'checksum': 82, + 'epfd': EpfdType.Surveyed, + 'fill_bits': 2, + 'frag_cnt': 1, + 'frag_num': 1, + 'lat': -32.004333, + 'lon': 115.691833, + 'mmsi': '995036013', + 'msg_type': 21, + 'name_ext': None, + 'off_position': False, + 'payload': 'E>lt;KLab21@1bb@I@@@@@@@@@@D8k2tnmvs000003v0@', + 'raim': None, + 'raw': '!AIVDM,1,1,,B,E>lt;KLab21@1bb@I@@@@@@@@@@D8k2tnmvs000003v0@,2*52', + 'regional': 4, + 'repeat': 0, + 'second': 60, + 'seq_id': None, + 'shipname': 'STDB CUT 2', + 'spare': None, + 'talker': 'AI', + 'to_bow': 0, + 'to_port': 0, + 'to_starboard': 0, + 'to_stern': 0, + 'type': 'VDM', + 'virtual_aid': None} + ) From be9b3848d8d451cde9f7c7e73f2390f3c51ff837 Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sat, 26 Feb 2022 15:59:14 +0100 Subject: [PATCH 12/18] Let enums return None if provided a None value --- pyais/constants.py | 43 ++++++++++++++++++++++++++++++++++++++- pyais/messages.py | 26 +++++++++++------------ tests/test_decode.py | 19 +++++++++++++++++ tests/test_file_stream.py | 15 ++++++++++++-- 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/pyais/constants.py b/pyais/constants.py index 72948be..a66f4db 100644 --- a/pyais/constants.py +++ b/pyais/constants.py @@ -1,3 +1,4 @@ +import typing from enum import IntEnum, Enum # Keywords @@ -24,9 +25,13 @@ class TalkerID(str, Enum): UNDEFINED = "UNDEFINED" @classmethod - def _missing_(cls, value: object) -> str: + def _missing_(cls, value: typing.Any) -> str: return TalkerID.UNDEFINED + @classmethod + def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["TalkerID"]: + return cls(v) if v is not None else None + class NavigationStatus(IntEnum): UnderWayUsingEngine = 0 @@ -45,6 +50,10 @@ class NavigationStatus(IntEnum): def _missing_(cls, value: object) -> int: return NavigationStatus.Undefined + @classmethod + def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["NavigationStatus"]: + return cls(v) if v is not None else None + class ManeuverIndicator(IntEnum): NotAvailable = 0 @@ -56,6 +65,10 @@ class ManeuverIndicator(IntEnum): def _missing_(cls, value: object) -> int: return ManeuverIndicator.UNDEFINED + @classmethod + def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["ManeuverIndicator"]: + return cls(v) if v is not None else None + class EpfdType(IntEnum): Undefined = 0 @@ -72,6 +85,10 @@ class EpfdType(IntEnum): def _missing_(cls, value: object) -> int: return EpfdType.Undefined + @classmethod + def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["EpfdType"]: + return cls(v) if v is not None else None + class ShipType(IntEnum): NotAvailable = 0 @@ -168,6 +185,10 @@ def _missing_(cls, value: object) -> int: return ShipType.NotAvailable + @classmethod + def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["ShipType"]: + return cls(v) if v is not None else None + class DacFid(IntEnum): DangerousCargoIndication = 13 @@ -183,6 +204,10 @@ class DacFid(IntEnum): AtoN_MonitoringData_UK = 245 AtoN_MonitoringData_ROI = 260 + @classmethod + def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["DacFid"]: + return cls(v) if v is not None else None + class NavAid(IntEnum): DEFAULT = 0 @@ -222,6 +247,10 @@ class NavAid(IntEnum): def _missing_(cls, value: object) -> int: return NavAid.DEFAULT + @classmethod + def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["NavAid"]: + return cls(v) if v is not None else None + class TransmitMode(IntEnum): TXA_TXB_RXA_RXB = 0 # default @@ -233,6 +262,10 @@ class TransmitMode(IntEnum): def _missing_(cls, value: object) -> int: return TransmitMode.TXA_TXB_RXA_RXB + @classmethod + def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["TransmitMode"]: + return cls(v) if v is not None else None + class StationType(IntEnum): ALL = 0 @@ -252,6 +285,10 @@ def _missing_(cls, value: object) -> int: return StationType.RESERVED return StationType.ALL + @classmethod + def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["StationType"]: + return cls(v) if v is not None else None + class StationIntervals(IntEnum): AUTONOMOUS_MODE = 0 @@ -270,3 +307,7 @@ class StationIntervals(IntEnum): @classmethod def _missing_(cls, value: object) -> int: return StationIntervals.RESERVED + + @classmethod + def from_value(cls, v: typing.Optional[typing.Any]) -> typing.Optional["StationIntervals"]: + return cls(v) if v is not None else None diff --git a/pyais/messages.py b/pyais/messages.py index c6612a0..63fffd3 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -500,7 +500,7 @@ class MessageType1(Payload): msg_type = bit_field(6, int, default=1) repeat = bit_field(2, int, default=0) mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) - status = bit_field(4, int, default=0, converter=NavigationStatus) + status = bit_field(4, int, default=0, converter=NavigationStatus.from_value) turn = bit_field(8, int, default=0, signed=True) speed = bit_field(10, int, from_converter=from_speed, to_converter=to_speed, default=0) accuracy = bit_field(1, int, default=0) @@ -509,7 +509,7 @@ class MessageType1(Payload): course = bit_field(12, int, from_converter=from_course, to_converter=to_course, default=0) heading = bit_field(9, int, default=0) second = bit_field(6, int, default=0) - maneuver = bit_field(2, int, default=0, converter=ManeuverIndicator) + maneuver = bit_field(2, int, default=0, converter=ManeuverIndicator.from_value) spare = bit_field(3, int, default=0) raim = bit_field(1, bool, default=0) radio = bit_field(19, int, default=0) @@ -549,7 +549,7 @@ class MessageType4(Payload): accuracy = bit_field(1, int, default=0) lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) - epfd = bit_field(4, int, default=0, converter=EpfdType) + epfd = bit_field(4, int, default=0, converter=EpfdType.from_value) spare = bit_field(10, int, default=0) raim = bit_field(1, int, default=0) radio = bit_field(19, int, default=0) @@ -568,12 +568,12 @@ class MessageType5(Payload): imo = bit_field(30, int, default=0) callsign = bit_field(42, str, default='') shipname = bit_field(120, str, default='') - ship_type = bit_field(8, int, default=0, converter=ShipType) + ship_type = bit_field(8, int, default=0, converter=ShipType.from_value) to_bow = bit_field(9, int, default=0) to_stern = bit_field(9, int, default=0) to_port = bit_field(6, int, default=0) to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0, converter=EpfdType) + epfd = bit_field(4, int, default=0, converter=EpfdType.from_value) month = bit_field(4, int, default=0) day = bit_field(5, int, default=0) hour = bit_field(5, int, default=0) @@ -825,12 +825,12 @@ class MessageType19(Payload): second = bit_field(6, int, default=0) regional = bit_field(4, int, default=0) shipname = bit_field(120, str, default='') - ship_type = bit_field(8, int, default=0, converter=ShipType) + ship_type = bit_field(8, int, default=0, converter=ShipType.from_value) to_bow = bit_field(9, int, default=0) to_stern = bit_field(9, int, default=0) to_port = bit_field(6, int, default=0) to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0, converter=EpfdType) + epfd = bit_field(4, int, default=0, converter=EpfdType.from_value) raim = bit_field(1, bool, default=0) dte = bit_field(1, bool, default=0) assigned = bit_field(1, int, default=0) @@ -879,7 +879,7 @@ class MessageType21(Payload): repeat = bit_field(2, int, default=0) mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) - aid_type = bit_field(5, int, default=0, converter=NavAid) + aid_type = bit_field(5, int, default=0, converter=NavAid.from_value) shipname = bit_field(120, str, default='') accuracy = bit_field(1, bool, default=0) lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) @@ -888,7 +888,7 @@ class MessageType21(Payload): to_stern = bit_field(9, int, default=0) to_port = bit_field(6, int, default=0) to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0, converter=EpfdType) + epfd = bit_field(4, int, default=0, converter=EpfdType.from_value) second = bit_field(6, int, default=0) off_position = bit_field(1, bool, default=0) regional = bit_field(8, int, default=0) @@ -1003,12 +1003,12 @@ class MessageType23(Payload): sw_lon = bit_field(18, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) sw_lat = bit_field(17, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) - station_type = bit_field(4, int, default=0, converter=StationType) - ship_type = bit_field(8, int, default=0, converter=ShipType) + station_type = bit_field(4, int, default=0, converter=StationType.from_value) + ship_type = bit_field(8, int, default=0, converter=ShipType.from_value) spare_2 = bit_field(22, int, default=0) - txrx = bit_field(2, int, default=0, converter=TransmitMode) - interval = bit_field(4, int, default=0, converter=StationIntervals) + txrx = bit_field(2, int, default=0, converter=TransmitMode.from_value) + interval = bit_field(4, int, default=0, converter=StationIntervals.from_value) quiet = bit_field(4, int, default=0) spare_3 = bit_field(6, int, default=0) diff --git a/tests/test_decode.py b/tests/test_decode.py index 0a0f929..8797ba7 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -708,6 +708,25 @@ def test_issue_46_b(self): self.assertIsNone(decoded.assigned) self.assertIsNone(decoded.name_ext) + def test_msg_too_short_enum_is_none(self): + msg = b"!AIVDM,1,1,,B,E>lt;,2*52" + decoded = NMEAMessage(msg).decode() + + self.assertEqual(decoded.msg_type, 21) + self.assertEqual(decoded.repeat, 0) + self.assertEqual(decoded.mmsi, '000971714') + self.assertIsNone(decoded.aid_type) + self.assertIsNone(decoded.epfd) + + msg = b"!AIVDM,1,1,,B,15M6,0*5C" + decoded = NMEAMessage(msg).decode() + self.assertIsNone(decoded.maneuver) + + msg = b"!AIVDM,2,1,1,A,55?MbV02;H,0*00" + decoded = NMEAMessage(msg).decode() + self.assertIsNone(decoded.ship_type) + self.assertIsNone(decoded.epfd) + def test_to_dict_non_enum(self): """Enum types do not use None if the fields are missing when partial decoding""" msg = b"!AIVDM,1,1,,B,E>lt;KLab21@1bb@I@@@@@@@@@@D8k2tnmvs000003v0@,2*52" diff --git a/tests/test_file_stream.py b/tests/test_file_stream.py index e19d62d..fdeb6b0 100644 --- a/tests/test_file_stream.py +++ b/tests/test_file_stream.py @@ -1,7 +1,9 @@ import pathlib +import time import unittest from unittest.case import skip +from pyais.exceptions import UnknownMessageException from pyais.messages import NMEAMessage from pyais.stream import FileReaderStream, should_parse, IterMessages @@ -151,11 +153,20 @@ def test_invalid_filename(self): @skip("This takes too long for now") def test_large_file(self): + start = time.time() # The ais sample data is downloaded from https://www.aishub.net/ais-dispatcher par_dir = pathlib.Path(__file__).parent.absolute() large_file = par_dir.joinpath("nmea-sample") - for msg in FileReaderStream(large_file): - msg.decode() + errors = 0 + for i, msg in enumerate(FileReaderStream(large_file)): + try: + msg.decode() + except UnknownMessageException: + errors += 1 + continue + + print(f"Decoding {i + 1} messages took:", time.time() - start) + print("ERRORS", errors) def test_marine_traffic_sample(self): """Test some messages from https://help.marinetraffic.com/hc/en-us From b5a51bd6184c9c052f2e83278f8488d87210f175 Mon Sep 17 00:00:00 2001 From: Inrixia Date: Sun, 6 Mar 2022 01:52:01 +1300 Subject: [PATCH 13/18] Fix enums of NoneType throwing when calling asdict with enum_as_int=True (#50) --- pyais/messages.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyais/messages.py b/pyais/messages.py index 63fffd3..2c59176 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -435,10 +435,9 @@ def asdict(self, enum_as_int: bool = False) -> typing.Dict[str, typing.Any]: @return: The message as a dictionary. """ if enum_as_int: - d = {slt: int(getattr(self, slt)) if slt in ENUM_FIELDS else getattr(self, slt) for slt in self.__slots__} + return { slt: None if getattr(self, slt) is None else int(getattr(self, slt)) if slt in ENUM_FIELDS else getattr(self, slt) for slt in self.__slots__ } else: - d = {slt: getattr(self, slt) for slt in self.__slots__} - return d + return { slt: getattr(self, slt) for slt in self.__slots__ } def to_json(self) -> str: return json.dumps( From 81b315c21db523b59d0c93bfc5ea7568f829d13f Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sat, 5 Mar 2022 14:01:43 +0100 Subject: [PATCH 14/18] improves readability of 'asdict()' and adds a unittest for enums with None value --- pyais/messages.py | 16 ++++++++++++---- tests/test_decode.py | 11 ++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/pyais/messages.py b/pyais/messages.py index 2c59176..07b533a 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -12,6 +12,8 @@ from pyais.util import decode_into_bit_array, compute_checksum, int_to_bin, str_to_bin, \ encode_ascii_6, from_bytes, int_to_bytes, from_bytes_signed, decode_bin_as_ascii6, get_int +NMEA_VALUE = typing.Union[str, float, int, bool, bytes] + def validate_message(msg: bytes) -> None: """ @@ -362,7 +364,7 @@ def encode(self) -> typing.Tuple[str, int]: return encode_ascii_6(bit_arr) @classmethod - def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_MESSAGE": + def create(cls, **kwargs: NMEA_VALUE) -> "ANY_MESSAGE": """ Create a new instance of each Payload class. @param kwargs: A set of keywords. For each field of `cls` a keyword with the same @@ -428,16 +430,22 @@ def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": return cls(**kwargs) # type:ignore - def asdict(self, enum_as_int: bool = False) -> typing.Dict[str, typing.Any]: + def asdict(self, enum_as_int: bool = False) -> typing.Dict[str, typing.Optional[NMEA_VALUE]]: """ Convert the message to a dictionary. @param enum_as_int: If set to True all Enum values will be returned as raw ints. @return: The message as a dictionary. """ if enum_as_int: - return { slt: None if getattr(self, slt) is None else int(getattr(self, slt)) if slt in ENUM_FIELDS else getattr(self, slt) for slt in self.__slots__ } + d: typing.Dict[str, typing.Optional[NMEA_VALUE]] = {} + for slt in self.__slots__: + val = getattr(self, slt) + if val is not None and slt in ENUM_FIELDS: + val = int(getattr(self, slt)) + d[slt] = val + return d else: - return { slt: getattr(self, slt) for slt in self.__slots__ } + return {slt: getattr(self, slt) for slt in self.__slots__} def to_json(self) -> str: return json.dumps( diff --git a/tests/test_decode.py b/tests/test_decode.py index 8797ba7..ddd4f2f 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -6,7 +6,7 @@ from pyais.constants import ManeuverIndicator, NavigationStatus, ShipType, NavAid, EpfdType, StationType, TransmitMode from pyais.decode import decode from pyais.exceptions import UnknownMessageException -from pyais.messages import MessageType18 +from pyais.messages import MessageType18, MessageType5 from pyais.stream import ByteStream @@ -833,3 +833,12 @@ def test_decode_and_merge(self): 'type': 'VDM', 'virtual_aid': None} ) + + def test_issue_50(self): + """Refer to PR: https://github.com/M0r13n/pyais/pull/50/files""" + msg = MessageType5.create(mmsi="123456", ship_type=None, epfd=None) + + dictionary = msg.asdict(enum_as_int=True) + + self.assertIsNone(dictionary['epfd']) + self.assertIsNone(dictionary['ship_type']) From 5e1c5295ba1db8759ecefa378bb23db1f726d1e7 Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sat, 5 Mar 2022 14:41:29 +0100 Subject: [PATCH 15/18] Fix some remaining bugs thanks to @Inrixia's contributions --- pyais/exceptions.py | 20 ++++++++++++++++---- pyais/messages.py | 45 +++++++++++++++++++++++++------------------- tests/test_decode.py | 44 +++++++++++++++++++++++++++++++++++++++++-- tests/test_encode.py | 5 +++-- 4 files changed, 87 insertions(+), 27 deletions(-) diff --git a/pyais/exceptions.py b/pyais/exceptions.py index 0df158f..d4137b9 100644 --- a/pyais/exceptions.py +++ b/pyais/exceptions.py @@ -1,16 +1,28 @@ -class InvalidNMEAMessageException(Exception): +class AISBaseException(Exception): + """The base exception for all exceptions""" + + +class InvalidNMEAMessageException(AISBaseException): """Invalid NMEA Message""" pass -class UnknownMessageException(Exception): +class UnknownMessageException(AISBaseException): """Message not supported yet""" pass -class MissingMultipartMessageException(Exception): +class MissingMultipartMessageException(AISBaseException): """Multipart message with missing parts provided""" -class TooManyMessagesException(Exception): +class TooManyMessagesException(AISBaseException): """Too many messages""" + + +class UnknownPartNoException(AISBaseException): + """Unknown part number""" + + +class InvalidDataTypeException(AISBaseException): + """An Unknown data type was passed to an encoding/decoding function""" diff --git a/pyais/messages.py b/pyais/messages.py index 07b533a..84e3e21 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -8,7 +8,8 @@ from pyais.constants import TalkerID, NavigationStatus, ManeuverIndicator, EpfdType, ShipType, NavAid, StationType, \ TransmitMode, StationIntervals -from pyais.exceptions import InvalidNMEAMessageException, UnknownMessageException +from pyais.exceptions import InvalidNMEAMessageException, UnknownMessageException, UnknownPartNoException, \ + InvalidDataTypeException from pyais.util import decode_into_bit_array, compute_checksum, int_to_bin, str_to_bin, \ encode_ascii_6, from_bytes, int_to_bytes, from_bytes_signed, decode_bin_as_ascii6, get_int @@ -342,6 +343,9 @@ def to_bitarray(self) -> bitarray: converter = field.metadata['from_converter'] val = getattr(self, field.name) + if val is None: + continue + val = converter(val) if converter is not None else val val = d_type(val) @@ -350,7 +354,7 @@ def to_bitarray(self) -> bitarray: elif d_type == str: bits = str_to_bin(val, width) else: - raise ValueError() + raise InvalidDataTypeException(d_type) bits = bits[:width] out += bits @@ -420,7 +424,7 @@ def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": elif d_type == str: val = decode_bin_as_ascii6(bits) else: - raise ValueError() + raise InvalidDataTypeException(d_type) val = converter(val) if converter is not None else val @@ -516,7 +520,8 @@ class MessageType1(Payload): course = bit_field(12, int, from_converter=from_course, to_converter=to_course, default=0) heading = bit_field(9, int, default=0) second = bit_field(6, int, default=0) - maneuver = bit_field(2, int, default=0, converter=ManeuverIndicator.from_value) + maneuver = bit_field(2, int, default=0, from_converter=ManeuverIndicator.from_value, + to_converter=ManeuverIndicator.from_value) spare = bit_field(3, int, default=0) raim = bit_field(1, bool, default=0) radio = bit_field(19, int, default=0) @@ -556,7 +561,7 @@ class MessageType4(Payload): accuracy = bit_field(1, int, default=0) lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) lat = bit_field(27, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) - epfd = bit_field(4, int, default=0, converter=EpfdType.from_value) + epfd = bit_field(4, int, default=0, from_converter=EpfdType.from_value, to_converter=EpfdType.from_value) spare = bit_field(10, int, default=0) raim = bit_field(1, int, default=0) radio = bit_field(19, int, default=0) @@ -575,12 +580,12 @@ class MessageType5(Payload): imo = bit_field(30, int, default=0) callsign = bit_field(42, str, default='') shipname = bit_field(120, str, default='') - ship_type = bit_field(8, int, default=0, converter=ShipType.from_value) + ship_type = bit_field(8, int, default=0, from_converter=ShipType.from_value, to_converter=ShipType.from_value) to_bow = bit_field(9, int, default=0) to_stern = bit_field(9, int, default=0) to_port = bit_field(6, int, default=0) to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0, converter=EpfdType.from_value) + epfd = bit_field(4, int, default=0, from_converter=EpfdType.from_value, to_converter=EpfdType.from_value) month = bit_field(4, int, default=0) day = bit_field(5, int, default=0) hour = bit_field(5, int, default=0) @@ -606,7 +611,7 @@ class MessageType6(Payload): spare = bit_field(1, int, default=0) dac = bit_field(10, int, default=0) fid = bit_field(6, int, default=0) - data = bit_field(920, int, default=0, converter=int_to_bytes) + data = bit_field(920, int, default=0, from_converter=int_to_bytes, to_converter=int_to_bytes) @attr.s(slots=True) @@ -832,12 +837,12 @@ class MessageType19(Payload): second = bit_field(6, int, default=0) regional = bit_field(4, int, default=0) shipname = bit_field(120, str, default='') - ship_type = bit_field(8, int, default=0, converter=ShipType.from_value) + ship_type = bit_field(8, int, default=0, from_converter=ShipType.from_value, to_converter=ShipType.from_value) to_bow = bit_field(9, int, default=0) to_stern = bit_field(9, int, default=0) to_port = bit_field(6, int, default=0) to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0, converter=EpfdType.from_value) + epfd = bit_field(4, int, default=0, from_converter=EpfdType.from_value, to_converter=EpfdType.from_value) raim = bit_field(1, bool, default=0) dte = bit_field(1, bool, default=0) assigned = bit_field(1, int, default=0) @@ -886,7 +891,7 @@ class MessageType21(Payload): repeat = bit_field(2, int, default=0) mmsi = bit_field(30, int, from_converter=from_mmsi, to_converter=to_mmsi) - aid_type = bit_field(5, int, default=0, converter=NavAid.from_value) + aid_type = bit_field(5, int, default=0, from_converter=NavAid.from_value, to_converter=NavAid.from_value) shipname = bit_field(120, str, default='') accuracy = bit_field(1, bool, default=0) lon = bit_field(28, int, from_converter=from_lat_lon, to_converter=to_lat_lon, signed=True, default=0) @@ -895,7 +900,7 @@ class MessageType21(Payload): to_stern = bit_field(9, int, default=0) to_port = bit_field(6, int, default=0) to_starboard = bit_field(6, int, default=0) - epfd = bit_field(4, int, default=0, converter=EpfdType.from_value) + epfd = bit_field(4, int, default=0, from_converter=EpfdType.from_value, to_converter=EpfdType.from_value) second = bit_field(6, int, default=0) off_position = bit_field(1, bool, default=0) regional = bit_field(8, int, default=0) @@ -1010,12 +1015,14 @@ class MessageType23(Payload): sw_lon = bit_field(18, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) sw_lat = bit_field(17, int, from_converter=from_course, to_converter=to_course, default=0, signed=True) - station_type = bit_field(4, int, default=0, converter=StationType.from_value) - ship_type = bit_field(8, int, default=0, converter=ShipType.from_value) + station_type = bit_field(4, int, default=0, from_converter=StationType.from_value, + to_converter=StationType.from_value) + ship_type = bit_field(8, int, default=0, from_converter=ShipType.from_value, to_converter=ShipType.from_value) spare_2 = bit_field(22, int, default=0) - txrx = bit_field(2, int, default=0, converter=TransmitMode.from_value) - interval = bit_field(4, int, default=0, converter=StationIntervals.from_value) + txrx = bit_field(2, int, default=0, from_converter=TransmitMode.from_value, to_converter=TransmitMode.from_value) + interval = bit_field(4, int, default=0, from_converter=StationIntervals.from_value, + to_converter=StationIntervals.from_value) quiet = bit_field(4, int, default=0) spare_3 = bit_field(6, int, default=0) @@ -1071,7 +1078,7 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_ME elif partno == 1: return MessageType24PartB.create(**kwargs) else: - raise ValueError(f"Partno {partno} is not allowed!") + raise UnknownPartNoException(f"Partno {partno} is not allowed!") @classmethod def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": @@ -1081,7 +1088,7 @@ def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": elif partno == 1: return MessageType24PartB.from_bitarray(bit_arr) else: - raise ValueError(f"Partno {partno} is not allowed!") + raise UnknownPartNoException(f"Partno {partno} is not allowed!") @attr.s(slots=True) @@ -1291,7 +1298,7 @@ class MessageType27(Payload): accuracy = bit_field(1, int, default=0) raim = bit_field(1, int, default=0) - status = bit_field(4, int, default=0, converter=NavigationStatus) + status = bit_field(4, int, default=0, from_converter=NavigationStatus, to_converter=NavigationStatus) lon = bit_field(18, int, from_converter=from_lat_lon_600, to_converter=to_lat_lon_600, default=0) lat = bit_field(17, int, from_converter=from_lat_lon_600, to_converter=to_lat_lon_600, default=0) speed = bit_field(6, int, default=0) diff --git a/tests/test_decode.py b/tests/test_decode.py index ddd4f2f..6d05444 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1,12 +1,12 @@ import textwrap import unittest -from pyais import NMEAMessage +from pyais import NMEAMessage, encode_dict from pyais.ais_types import AISType from pyais.constants import ManeuverIndicator, NavigationStatus, ShipType, NavAid, EpfdType, StationType, TransmitMode from pyais.decode import decode from pyais.exceptions import UnknownMessageException -from pyais.messages import MessageType18, MessageType5 +from pyais.messages import MessageType18, MessageType5, MessageType6, MSG_CLASS from pyais.stream import ByteStream @@ -842,3 +842,43 @@ def test_issue_50(self): self.assertIsNone(dictionary['epfd']) self.assertIsNone(dictionary['ship_type']) + + def test_none_value_converter_for_creation(self): + """Make sure that None values can be encoded -> left out""" + msg = MessageType6.create(mmsi="123456", dest_mmsi=None, data=None) + self.assertIsNone(msg.data) + + def test_none_value_converter_for_decoding(self): + """Make sure that None values do not cause any problems when decoding""" + encoded = encode_dict({"mmsi": "123456", "dest_mmsi": None, "data": None, 'msg_type': 6}) + encoded = encoded[0] + decoded = decode(encoded) + self.assertIsNone(decoded.data) + + def test_none_values_converter_for_all_messages(self): + """ + Create the shortest possible message that could potentially occur and try to decode it again. + This is done to ensure, that there are no hiccups when trying to decode very short messages. + """ + for mtype in range(28): + cls = MSG_CLASS[mtype] + fields = set(f.name for f in cls.fields()) + payload = {f: None for f in fields} + payload['mmsi'] = 1337 + payload['msg_type'] = mtype + encoded = encode_dict(payload) + + self.assertIsNotNone(encoded) + + decoded = decode(*encoded) + + for field in fields: + val = getattr(decoded, field) + if field == 'mmsi': + self.assertEqual(val, '000001337') + elif field == 'msg_type': + self.assertEqual(val, mtype) + elif field == 'repeat': + self.assertEqual(val, 0) + else: + self.assertIsNone(val) diff --git a/tests/test_encode.py b/tests/test_encode.py index ac3b25d..21e54b1 100644 --- a/tests/test_encode.py +++ b/tests/test_encode.py @@ -5,6 +5,7 @@ from pyais import encode_dict, encode_msg from pyais.decode import decode from pyais.encode import data_to_payload, get_ais_type +from pyais.exceptions import UnknownPartNoException from pyais.messages import MessageType1, MessageType26BroadcastUnstructured, MessageType26AddressedUnstructured, \ MessageType26BroadcastStructured, MessageType26AddressedStructured, MessageType25BroadcastUnstructured, \ MessageType25AddressedUnstructured, MessageType25BroadcastStructured, MessageType25AddressedStructured, \ @@ -317,10 +318,10 @@ def test_encode_type_24_partno_invalid(): # Should not raise an error encode_dict({'mmsi': 123, 'partno': 1, 'type': 24}) - with unittest.TestCase().assertRaises(ValueError): + with unittest.TestCase().assertRaises(UnknownPartNoException): encode_dict({'mmsi': 123, 'partno': 2, 'type': 24}) - with unittest.TestCase().assertRaises(ValueError): + with unittest.TestCase().assertRaises(UnknownPartNoException): encode_dict({'mmsi': 123, 'partno': 3, 'type': 24}) From 774a1996495540ccf8da01218af72bdcaba3fb91 Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sat, 5 Mar 2022 15:40:22 +0100 Subject: [PATCH 16/18] Add a migration notice from v1 to v2 --- README.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 943c8fc..e26838a 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,6 @@ TCP/UDP sockets. You can find the full documentation on [readthedocs](https://pyais.readthedocs.io/en/latest/). -| :exclamation: The whole project was been partially rewritten. So expect breaking changes when upgrading from v1 to v2. You can install a preview with `pip install pyais==2.0.0-alpha` | -|---------------------------------------------------------------------------------------------------------------------------------------------------------| - # Acknowledgements ![Jetbrains Logo](./docs/jetbrains_logo.svg) @@ -33,6 +30,30 @@ the [AIS standard](https://en.wikipedia.org/wiki/Automatic_identification_system I open to any form of idea to further improve this library. If you have an idea or a feature request - just open an issue. :-) +# Migrating from v1 to v2 + +Version 2.0.0 of pyais had some breaking changes that were needed to improve the lib. While I tried to keep those +breaking changes to a minimum, there are a few places that got changed. + +* `msg.decode()` does not return a `pyais.messages.AISMessage` instance anymore + * instead an instance of `pyais.messages.MessageTypeX` is returned, where `X` is the type of the message (1-27) +* in v1 you called `decoded.content` to get the decoded message as a dictionary - this is now `decoded.asdict()` + +### Typical example in v1 + +```py +message = NMEAMessage(b"!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C") +decoded = message.decode() +data = decoded.content +``` + +### Typical example in v2 +```py +message = NMEAMessage(b"!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C") +decoded = message.decode() +data = decoded.asdict() +``` + # Installation The project is available at Pypi: From fc8a7af448ce7aa5f60ee48b0cec5d74053d6207 Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sun, 13 Mar 2022 13:30:09 +0100 Subject: [PATCH 17/18] Prevent out of bounds array access for very short messages --- pyais/messages.py | 10 +++++----- tests/test_decode.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/pyais/messages.py b/pyais/messages.py index 84e3e21..7745705 100644 --- a/pyais/messages.py +++ b/pyais/messages.py @@ -993,7 +993,7 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_ME @classmethod def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": - if bit_arr[139]: + if get_int(bit_arr, 139, 140): return MessageType22Addressed.from_bitarray(bit_arr) else: return MessageType22Broadcast.from_bitarray(bit_arr) @@ -1171,8 +1171,8 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_ME @classmethod def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": - addressed: int = bit_arr[38] - structured: int = bit_arr[39] + addressed: int = get_int(bit_arr, 38, 39) + structured: int = get_int(bit_arr, 39, 40) if addressed: if structured: @@ -1271,8 +1271,8 @@ def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_ME @classmethod def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE": - addressed: int = bit_arr[38] - structured: int = bit_arr[39] + addressed: int = get_int(bit_arr, 38, 39) + structured: int = get_int(bit_arr, 39, 40) if addressed: if structured: diff --git a/tests/test_decode.py b/tests/test_decode.py index 6d05444..03ab5ac 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -882,3 +882,24 @@ def test_none_values_converter_for_all_messages(self): self.assertEqual(val, 0) else: self.assertIsNone(val) + + def test_type_25_very_short(self): + """If the message is very short, an IndexError might o occur""" + short_msg = b'!AIVDO,1,1,,A,Ig,0*65' + decoded = decode(short_msg) + + self.assertEqual(decoded.mmsi, '000000015') + + def test_type_26_very_short(self): + """If the message is very short, an IndexError might occur""" + short_msg = b'!AIVDO,1,1,,A,Jgg,4*4E' + decoded = decode(short_msg) + + self.assertEqual(decoded.mmsi, '000000062') + + def test_type_22_very_short(self): + """If the mssage is very short an IndexError might occur""" + short_msg = b'!AIVDO,1,1,,A,F0001,0*74' + decoded = decode(short_msg) + + self.assertEqual(decoded.mmsi, '000000001') From 6c863b6984c37d4936f21b83c85a86336262808f Mon Sep 17 00:00:00 2001 From: Leon Morten Richter Date: Sun, 13 Mar 2022 13:40:32 +0100 Subject: [PATCH 18/18] Update changelog --- CHANGELOG.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index e2d102b..867ea54 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -8,6 +8,15 @@ * WARNING: The v2 release will introduce breaking changes * Introduces the possibility to encode messages * decoding has been rewritten and implements an iterative decoding approach +* The following fields were renamed: + * message_fragments -> frag_cnt + * fragment_number -> frag_num + * message_id -> seq_id + * type -> msg_type + * shiptype -> ship_type +* `msg.decode()` does not return a `pyais.messages.AISMessage` instance anymore + * instead an instance of `pyais.messages.MessageTypeX` is returned, where `X` is the type of the message (1-27) +* in v1 you called `decoded.content` to get the decoded message as a dictionary - this is now `decoded.asdict()` -------------------------------------------------------------------------------