Skip to content

Commit 04c33cd

Browse files
authored
chore: auxiliary craft now decode mothership MMSI instead of vessel dimensions (#196) (#197)
1 parent f0b3e06 commit 04c33cd

File tree

5 files changed

+92
-3
lines changed

5 files changed

+92
-3
lines changed

CHANGELOG.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
====================
22
pyais CHANGELOG
33
====================
4+
-------------------------------------------------------------------------------
5+
Version 2.14.0 07 Dec 2025
6+
-------------------------------------------------------------------------------
7+
* closes: https://github.com/M0r13n/pyais/issues/196
8+
* add support for AIS Message Type 24 Part B auxiliary craft variant
9+
* auxiliary craft now decode mothership MMSI instead of vessel dimensions
410
-------------------------------------------------------------------------------
511
Version 2.13.3 15 Nov 2025
612
-------------------------------------------------------------------------------

pyais/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pyais.tracker import AISTracker, AISTrack
66

77
__license__ = 'MIT'
8-
__version__ = '2.13.3'
8+
__version__ = '2.14.0'
99
__author__ = 'Leon Morten Richter'
1010

1111
__all__ = (

pyais/messages.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
InvalidDataTypeException, MissingPayloadException
1616
from pyais.util import checksum, decode_into_bit_array, compute_checksum, get_itdma_comm_state, get_sotdma_comm_state, int_to_bin, str_to_bin, \
1717
encode_ascii_6, from_bytes, from_bytes_signed, decode_bin_as_ascii6, get_int, chk_to_int, coerce_val, \
18-
bits2bytes, bytes2bits, b64encode_str
18+
bits2bytes, bytes2bits, b64encode_str, is_auxiliary_craft
1919

2020
NMEA_VALUE = typing.Union[str, float, int, bool, bytes]
2121

@@ -1701,6 +1701,32 @@ class MessageType24PartB(Payload):
17011701
spare_1 = bit_field(6, bytes, default=b'', is_spare=True)
17021702

17031703

1704+
@attr.s(slots=True)
1705+
class MessageType24PartBAuxiliaryCraft(Payload):
1706+
"""
1707+
Static Data Report - Part B (Auxiliary Craft Variant)
1708+
1709+
When the MMSI follows the pattern 98XXXYYYY (auxiliary craft),
1710+
bits 132-161 contain the mothership MMSI instead of vessel dimensions.
1711+
1712+
See ITU-R M.1371-5 for specification details.
1713+
"""
1714+
msg_type = bit_field(6, int, default=24, signed=False)
1715+
repeat = bit_field(2, int, default=0, signed=False)
1716+
mmsi = bit_field(30, int, from_converter=from_mmsi)
1717+
1718+
partno = bit_field(2, int, default=0, signed=False)
1719+
ship_type = bit_field(8, int, default=0, signed=False)
1720+
vendorid = bit_field(18, str, default='', signed=False)
1721+
model = bit_field(4, int, default=0, signed=False)
1722+
serial = bit_field(20, int, default=0, signed=False)
1723+
callsign = bit_field(42, str, default='')
1724+
1725+
mothership_mmsi = bit_field(30, int, from_converter=from_mmsi)
1726+
1727+
spare_1 = bit_field(6, bytes, default=b'', is_spare=True)
1728+
1729+
17041730
class MessageType24(Payload):
17051731
"""
17061732
Static Data Report
@@ -1714,20 +1740,26 @@ class MessageType24(Payload):
17141740

17151741
@classmethod
17161742
def create(cls, **kwargs: typing.Union[str, float, int, bool, bytes]) -> "ANY_MESSAGE":
1743+
mmsi: int = int(kwargs.get('mmsi', 0))
17171744
partno: int = int(kwargs.get('partno', 0))
17181745
if partno == 0:
17191746
return MessageType24PartA.create(**kwargs)
17201747
elif partno == 1:
1748+
if is_auxiliary_craft(mmsi):
1749+
return MessageType24PartBAuxiliaryCraft.create(**kwargs)
17211750
return MessageType24PartB.create(**kwargs)
17221751
else:
17231752
raise UnknownPartNoException(f"Partno {partno} is not allowed!")
17241753

17251754
@classmethod
17261755
def from_bitarray(cls, bit_arr: bitarray) -> "ANY_MESSAGE":
1756+
mmsi: int = get_int(bit_arr, 8, 38)
17271757
partno: int = get_int(bit_arr, 38, 40)
17281758
if partno == 0:
17291759
return MessageType24PartA.from_bitarray(bit_arr)
17301760
elif partno == 1:
1761+
if is_auxiliary_craft(mmsi):
1762+
return MessageType24PartBAuxiliaryCraft.from_bitarray(bit_arr)
17311763
return MessageType24PartB.from_bitarray(bit_arr)
17321764
else:
17331765
raise UnknownPartNoException(f"Partno {partno} is not allowed!")

pyais/util.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,11 @@ def get_first_three_digits(num: int) -> int:
430430

431431
def get_country(mmsi: int) -> typing.Tuple[str, str]:
432432
return COUNTRY_MAPPING.get(get_first_three_digits(mmsi), ('NA', 'Unknown'))
433+
434+
435+
def is_auxiliary_craft(mmsi: int) -> bool:
436+
# An auxiliary craft has a mmsi of the form 98XXXYYYY, where:
437+
# 1. it starts with 98
438+
# 2. is followed by a MID (XXX)
439+
# 3. if followed by any decimal literal YYYY (0000 - 9999)
440+
return 98_000_0000 <= mmsi <= 98_999_9999

tests/test_decode.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
MessageType26BroadcastUnstructured,
5353
)
5454
from pyais.stream import ByteStream, IterMessages
55-
from pyais.util import b64encode_str, bits2bytes, bytes2bits, decode_into_bit_array
55+
from pyais.util import b64encode_str, bits2bytes, bytes2bits, decode_into_bit_array, is_auxiliary_craft
5656
from pyais.exceptions import MissingPayloadException
5757

5858

@@ -763,6 +763,49 @@ def test_msg_type_24_with_168_bits(self):
763763
assert msg["mmsi"] == 123456789
764764
assert msg["spare_1"] == b"\x00"
765765

766+
def test_msg_type_24_b_regular(self):
767+
msg = decode(b"!AIVDO,1,1,,A,H8=;nnT000000000000000Wg8Jb0,0*26").asdict(ignore_spare=False)
768+
769+
assert msg["msg_type"] == 24
770+
assert msg["partno"] == 1
771+
assert msg["mmsi"] == 550696666
772+
assert msg["to_bow"] == 317
773+
assert msg["to_stern"] == 456
774+
assert msg["to_port"] == 26
775+
assert msg["to_starboard"] == 42
776+
assert 'mothership_mmsi' not in msg
777+
778+
def test_msg_type_24_b_aux_craft(self):
779+
msg = decode(b"!AIVDO,1,1,,A,H>W@vFTe6??406t2??21J0Wg8Jb0,0*6F").asdict(ignore_spare=False)
780+
781+
assert msg["msg_type"] == 24
782+
assert msg["partno"] == 1
783+
assert msg["mmsi"] == 980696666
784+
assert msg["ship_type"] == 45
785+
assert msg["vendorid"] == 'FOO'
786+
assert msg["model"] == 1
787+
assert msg["callsign"] == 'BOOBAZ'
788+
789+
assert 'to_bow' not in msg
790+
assert 'to_stern' not in msg
791+
assert 'to_port' not in msg
792+
assert 'to_starboard' not in msg
793+
794+
assert msg["mothership_mmsi"] == 666666666
795+
796+
def test_is_auxiliary_craft_boundary_cases(self):
797+
# Test lower boundary
798+
assert is_auxiliary_craft(980000000)
799+
800+
# Test upper boundary
801+
assert is_auxiliary_craft(989999999)
802+
803+
# Test just below range (should use regular PartB)
804+
assert not is_auxiliary_craft(979999999)
805+
806+
# Test just above range (should use regular PartB)
807+
assert not is_auxiliary_craft(990000000)
808+
766809
def test_msg_type_25_a(self):
767810
msg = decode(b"!AIVDM,1,1,,A,I6SWo?8P00a3PKpEKEVj0?vNP<65,0*73").asdict(ignore_spare=False)
768811

0 commit comments

Comments
 (0)