Repository: M0r13n/pyais

Issue:
Message 21 name + name_ext concatenation
It would be nice to pre-concatenate `name_ext` onto `name` when decoding message type 21, perhaps on an attribute `full_name`: https://github.com/biiont/gpsd/blob/master/www/AIVDM.txt#L3690-L3693

> The name field is up to 20 characters of 6-bit ASCII.  If this field
> is full (has no trailing @ characters) the decoder should interpret
> the Name Extension field later in the message (no more than 14 6-bit
> characters) and concatenate it to this one to obtain the full name.

Or at least provide a method that returns the full name.

e.g. in this example `full_name` would be `NNG-OSS-S OFFSHORE WINDFARM`

```
!AIVDM,1,1,,B,E>jHDL1W73nWaanah7S39T7a2h;wror=@5nL`A2AISd002CQ1PDS@0,4*39

print(pyais.decode('!AIVDM,1,1,,B,E>jHDL1W73nWaanah7S39T7a2h;wror=@5nL`A2AISd002CQ1PDS@0,4*39').to_json())

{
    "msg_type": 21,
    "repeat": 0,
    "mmsi": 992351344,
    "aid_type": 3,
    "name": "NNG-OSS-S OFFSHORE W",
    "accuracy": true,
    "lon": -2.239765,
    "lat": 56.243582,
    "to_bow": 17,
    "to_stern": 18,
    "to_port": 11,
    "to_starboard": 12,
    "epfd": 7,
    "second": 24,
    "off_position": false,
    "reserved_1": 0,
    "raim": false,
    "virtual_aid": false,
    "assigned": false,
    "spare_1": "AA==",
    "name_ext": "INDFARM"
}
```

Hints from issue comments:
@M0r13n: Hey @jwheare,

that is a neat idea. Thank you for bringing this up. I have added a (cached) property, `full_name`, to message type 21 in v2.8.4 which should  now be available on PyPi.

All the best,
Leon@jwheare: Brilliant, thank you!

Solution patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index d11cec1..d07cbe8 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -12,16 +12,15 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python: ['3.8', '3.9', '3.10', '3.11', '3.12']
+        python: ['3.9', '3.10', '3.11', '3.12', '3.13']
         os: ['ubuntu-latest']
     steps:
       - uses: actions/checkout@master
 
       - name: Setup python
-        uses: actions/setup-python@v1
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python }}
-          architecture: x64
 
       - name: Install requirements
         run: pip install -r requirements.txt
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index c3bf425..93a3d46 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -1,6 +1,11 @@
 ====================
  pyais CHANGELOG
 ====================
+-------------------------------------------------------------------------------
+ Version 2.8.4 26 Jan 2025
+-------------------------------------------------------------------------------
+* closes #161: https://github.com/M0r13n/pyais/issues/161
+  * adds property `full_name` to message type 21
 -------------------------------------------------------------------------------
  Version 2.8.3 20 Dec 2024
 -------------------------------------------------------------------------------
diff --git a/pyais/__init__.py b/pyais/__init__.py
index 5740ed8..37f71ed 100644
--- a/pyais/__init__.py
+++ b/pyais/__init__.py
@@ -5,7 +5,7 @@
 from pyais.tracker import AISTracker, AISTrack
 
 __license__ = 'MIT'
-__version__ = '2.8.3'
+__version__ = '2.8.4'
 __author__ = 'Leon Morten Richter'
 
 __all__ = (
diff --git a/pyais/messages.py b/pyais/messages.py
index 39e8613..026ad76 100644
--- a/pyais/messages.py
+++ b/pyais/messages.py
@@ -1329,6 +1329,18 @@ class MessageType21(Payload):
     spare_1 = bit_field(1, bytes, default=b'')
     name_ext = bit_field(88, str, default='')
 
+    @functools.cached_property
+    def full_name(self) -> str:
+        """The name field is up to 20 characters of 6-bit ASCII. If this field
+        is full (has no trailing @ characters) the decoder should interpret
+        the Name Extension field later in the message (no more than 14 6-bit
+        characters) and concatenate it to this one to obtain the full name."""
+        if self.name:
+            if self.name_ext:
+                return f"{self.name}{self.name_ext}"
+            return str(self.name)
+        return ""
+
 
 @attr.s(slots=True)
 class MessageType22Addressed(Payload):


Test patch:
diff --git a/tests/test_decode.py b/tests/test_decode.py
index 6e21a37..d74a152 100644
--- a/tests/test_decode.py
+++ b/tests/test_decode.py
@@ -961,6 +961,7 @@ def test_to_dict_non_enum(self):
                 "aid_type": 25,
                 "assigned": None,
                 "epfd": 7,
+                'full_name': 'STDB CUT 2',
                 "lat": -32.004333,
                 "lon": 115.691833,
                 "mmsi": 995036013,
@@ -986,7 +987,6 @@ def test_decode_and_merge(self):
         decoded = NMEAMessage(msg)
 
         d = decoded.decode_and_merge(enum_as_int=True)
-
         self.assertEqual(
             d,
             {
@@ -1001,6 +1001,7 @@ def test_decode_and_merge(self):
                 "fill_bits": 2,
                 "frag_cnt": 1,
                 "frag_num": 1,
+                'full_name': 'STDB CUT 2',
                 "lat": -32.004333,
                 "lon": 115.691833,
                 "mmsi": 995036013,
@@ -1041,6 +1042,7 @@ def test_decode_and_merge(self):
                 "fill_bits": 2,
                 "frag_cnt": 1,
                 "frag_num": 1,
+                'full_name': 'STDB CUT 2',
                 "lat": -32.004333,
                 "lon": 115.691833,
                 "mmsi": 995036013,
@@ -1768,3 +1770,9 @@ def test_decode_with_empty_payload(self):
             )
 
         self.assertEqual(str(err.exception), '!AIVDM,1,1,0,A,,0*16')
+
+    def test_decode_type_21_full_name(self):
+        raw = b"!AIVDM,1,1,,B,E>jHDL1W73nWaanah7S39T7a2h;wror=@5nL`A2AISd002CQ1PDS@0,4*39"
+        decoded = decode(raw)
+
+        assert decoded.full_name == "NNG-OSS-S OFFSHORE WINDFARM"