maritime-frame 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- maritime_frame-0.1.0/LICENSE +21 -0
- maritime_frame-0.1.0/MANIFEST.in +1 -0
- maritime_frame-0.1.0/PKG-INFO +82 -0
- maritime_frame-0.1.0/README.md +69 -0
- maritime_frame-0.1.0/pyproject.toml +16 -0
- maritime_frame-0.1.0/setup.cfg +4 -0
- maritime_frame-0.1.0/src/maritime_frame/__init__.py +12 -0
- maritime_frame-0.1.0/src/maritime_frame/ais.py +66 -0
- maritime_frame-0.1.0/src/maritime_frame/legacy.py +20 -0
- maritime_frame-0.1.0/src/maritime_frame/models.py +57 -0
- maritime_frame-0.1.0/src/maritime_frame/n2k.py +73 -0
- maritime_frame-0.1.0/src/maritime_frame/nmea0183.py +55 -0
- maritime_frame-0.1.0/src/maritime_frame/onenet.py +21 -0
- maritime_frame-0.1.0/src/maritime_frame/parser.py +83 -0
- maritime_frame-0.1.0/src/maritime_frame/proprietary.py +23 -0
- maritime_frame-0.1.0/src/maritime_frame.egg-info/PKG-INFO +82 -0
- maritime_frame-0.1.0/src/maritime_frame.egg-info/SOURCES.txt +18 -0
- maritime_frame-0.1.0/src/maritime_frame.egg-info/dependency_links.txt +1 -0
- maritime_frame-0.1.0/src/maritime_frame.egg-info/top_level.txt +1 -0
- maritime_frame-0.1.0/tests/test_parser.py +48 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maritime Frame Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include README.md
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maritime-frame
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency streaming parsers for NMEA and related marine telemetry protocols
|
|
5
|
+
Author: Maritime Frame Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Topic :: System :: Hardware
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# maritime-frame
|
|
15
|
+
|
|
16
|
+
`maritime-frame` is a zero-runtime-dependency Python library for incremental NMEA and marine telemetry decoding. It is designed for applications that receive arbitrary serial, TCP, UDP, or CAN fragments and cannot assume that one read equals one frame.
|
|
17
|
+
|
|
18
|
+
## Install and test
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m pip install .
|
|
22
|
+
python -m unittest discover -s tests -v
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Streaming NMEA-0183 and AIS
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from maritime_frame import StreamParser, Status
|
|
29
|
+
|
|
30
|
+
parser = StreamParser(max_sentence=1024)
|
|
31
|
+
for chunk in serial_port:
|
|
32
|
+
for event in parser.feed(chunk):
|
|
33
|
+
if event.status is Status.VALID_FRAME:
|
|
34
|
+
print(event.protocol, event.message)
|
|
35
|
+
elif event.status is Status.CORRUPTED_FRAME:
|
|
36
|
+
logger.warning("discarded frame: %s", event.error)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`feed()` accepts `str` or ASCII `bytes`, retains incomplete lines, rejects overlong input, validates the optional two-digit XOR checksum, and recognizes `$` and `!` prefixes. `ddm_to_decimal("4807.038", "N")` returns `48.1173`. AIS `!AIVDM` and `!AIVDO` payloads are 6-bit unarmored into a bit string; message types 1, 2, 3, and 5 expose MMSI, position, speed, course, heading, identity, and ship dimensions. Multi-sentence AIS messages are reassembled by sequence ID.
|
|
40
|
+
|
|
41
|
+
## NMEA-2000 CAN and Fast Packet
|
|
42
|
+
|
|
43
|
+
A 29-bit CAN identifier is mapped as follows:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
28 26 25 24 23 16 15 8 7 0
|
|
47
|
+
+----------+--+--+-----------+------------+---------+
|
|
48
|
+
| priority |R |DP| PDU format|PDU specific| source |
|
|
49
|
+
+----------+--+--+-----------+------------+---------+
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from maritime_frame import StreamParser
|
|
54
|
+
|
|
55
|
+
parser = StreamParser(n2k_timeout=1.0)
|
|
56
|
+
first = parser.feed_can(0x0CF00501, bytes([0, 10, 1, 2, 3, 4, 5, 6]), 100.0)
|
|
57
|
+
second = parser.feed_can(0x0CF00501, bytes([1, 7, 8, 9, 10, 0, 0, 0]), 100.1)
|
|
58
|
+
assert second.message.payload == bytes(range(1, 11))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The assembler enforces frame number order, a maximum payload of 223 bytes, exact eight-byte CAN data frames, and timeout purging before each frame. Dropped or late follow-up frames never remain in memory indefinitely.
|
|
62
|
+
|
|
63
|
+
## OneNet, legacy, and proprietary layers
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from maritime_frame.onenet import parse_datagram
|
|
67
|
+
from maritime_frame.proprietary import ProprietaryRegistry
|
|
68
|
+
|
|
69
|
+
network = parse_datagram(b"UdPBc $GPRMC,...")
|
|
70
|
+
registry = ProprietaryRegistry()
|
|
71
|
+
registry.register("PGR", lambda sentence: {"vendor": "Garmin", "fields": sentence.fields})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`parse_datagram` validates the `UdP*`/`TcP*` transport token and preserves the payload for the normal stream parser. `legacy.parse_0180` and `legacy.parse_0182` decode steering and bearing flags. The proprietary registry is intentionally open-ended so vendor payload schemas remain application-owned.
|
|
75
|
+
|
|
76
|
+
## Status contract
|
|
77
|
+
|
|
78
|
+
Every chunk or CAN frame yields one deterministic status: `VALID_FRAME`, `PARTIAL_STREAM`, or `CORRUPTED_FRAME`. A partial NMEA line produces a partial event while its bytes remain buffered. Invalid checksums, invalid AIS armor, overflows, malformed transport headers, and N2K sequencing errors produce corrupted events without exposing unsafe indexes or unbounded allocations.
|
|
79
|
+
|
|
80
|
+
## Scope and transport boundary
|
|
81
|
+
|
|
82
|
+
The package parses frames; it does not open sockets, configure serial ports, or control a CAN adapter. That keeps it portable and lets downstream systems choose their I/O, scheduling, timestamp, and multicast policy. All runtime code uses only the Python standard library.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# maritime-frame
|
|
2
|
+
|
|
3
|
+
`maritime-frame` is a zero-runtime-dependency Python library for incremental NMEA and marine telemetry decoding. It is designed for applications that receive arbitrary serial, TCP, UDP, or CAN fragments and cannot assume that one read equals one frame.
|
|
4
|
+
|
|
5
|
+
## Install and test
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m pip install .
|
|
9
|
+
python -m unittest discover -s tests -v
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Streaming NMEA-0183 and AIS
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from maritime_frame import StreamParser, Status
|
|
16
|
+
|
|
17
|
+
parser = StreamParser(max_sentence=1024)
|
|
18
|
+
for chunk in serial_port:
|
|
19
|
+
for event in parser.feed(chunk):
|
|
20
|
+
if event.status is Status.VALID_FRAME:
|
|
21
|
+
print(event.protocol, event.message)
|
|
22
|
+
elif event.status is Status.CORRUPTED_FRAME:
|
|
23
|
+
logger.warning("discarded frame: %s", event.error)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`feed()` accepts `str` or ASCII `bytes`, retains incomplete lines, rejects overlong input, validates the optional two-digit XOR checksum, and recognizes `$` and `!` prefixes. `ddm_to_decimal("4807.038", "N")` returns `48.1173`. AIS `!AIVDM` and `!AIVDO` payloads are 6-bit unarmored into a bit string; message types 1, 2, 3, and 5 expose MMSI, position, speed, course, heading, identity, and ship dimensions. Multi-sentence AIS messages are reassembled by sequence ID.
|
|
27
|
+
|
|
28
|
+
## NMEA-2000 CAN and Fast Packet
|
|
29
|
+
|
|
30
|
+
A 29-bit CAN identifier is mapped as follows:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
28 26 25 24 23 16 15 8 7 0
|
|
34
|
+
+----------+--+--+-----------+------------+---------+
|
|
35
|
+
| priority |R |DP| PDU format|PDU specific| source |
|
|
36
|
+
+----------+--+--+-----------+------------+---------+
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from maritime_frame import StreamParser
|
|
41
|
+
|
|
42
|
+
parser = StreamParser(n2k_timeout=1.0)
|
|
43
|
+
first = parser.feed_can(0x0CF00501, bytes([0, 10, 1, 2, 3, 4, 5, 6]), 100.0)
|
|
44
|
+
second = parser.feed_can(0x0CF00501, bytes([1, 7, 8, 9, 10, 0, 0, 0]), 100.1)
|
|
45
|
+
assert second.message.payload == bytes(range(1, 11))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The assembler enforces frame number order, a maximum payload of 223 bytes, exact eight-byte CAN data frames, and timeout purging before each frame. Dropped or late follow-up frames never remain in memory indefinitely.
|
|
49
|
+
|
|
50
|
+
## OneNet, legacy, and proprietary layers
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from maritime_frame.onenet import parse_datagram
|
|
54
|
+
from maritime_frame.proprietary import ProprietaryRegistry
|
|
55
|
+
|
|
56
|
+
network = parse_datagram(b"UdPBc $GPRMC,...")
|
|
57
|
+
registry = ProprietaryRegistry()
|
|
58
|
+
registry.register("PGR", lambda sentence: {"vendor": "Garmin", "fields": sentence.fields})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`parse_datagram` validates the `UdP*`/`TcP*` transport token and preserves the payload for the normal stream parser. `legacy.parse_0180` and `legacy.parse_0182` decode steering and bearing flags. The proprietary registry is intentionally open-ended so vendor payload schemas remain application-owned.
|
|
62
|
+
|
|
63
|
+
## Status contract
|
|
64
|
+
|
|
65
|
+
Every chunk or CAN frame yields one deterministic status: `VALID_FRAME`, `PARTIAL_STREAM`, or `CORRUPTED_FRAME`. A partial NMEA line produces a partial event while its bytes remain buffered. Invalid checksums, invalid AIS armor, overflows, malformed transport headers, and N2K sequencing errors produce corrupted events without exposing unsafe indexes or unbounded allocations.
|
|
66
|
+
|
|
67
|
+
## Scope and transport boundary
|
|
68
|
+
|
|
69
|
+
The package parses frames; it does not open sockets, configure serial ports, or control a CAN adapter. That keeps it portable and lets downstream systems choose their I/O, scheduling, timestamp, and multicast policy. All runtime code uses only the Python standard library.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "maritime-frame"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Zero-dependency streaming parsers for NMEA and related marine telemetry protocols"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Maritime Frame Contributors"}]
|
|
13
|
+
classifiers = ["Programming Language :: Python :: 3", "Topic :: System :: Hardware"]
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
where = ["src"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Streaming marine telemetry frame parsing."""
|
|
2
|
+
from .models import AisMessage, CanFrame, N2kMessage, NmeaSentence, ParseEvent, Status
|
|
3
|
+
from .nmea0183 import checksum, ddm_to_decimal, parse_sentence
|
|
4
|
+
from .ais import decode as decode_ais
|
|
5
|
+
from .n2k import FastPacketAssembler, decode_can_id
|
|
6
|
+
from .parser import StreamParser
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AisMessage", "CanFrame", "FastPacketAssembler", "N2kMessage", "NmeaSentence",
|
|
10
|
+
"ParseEvent", "Status", "StreamParser", "checksum", "decode_ais", "decode_can_id",
|
|
11
|
+
"ddm_to_decimal", "parse_sentence",
|
|
12
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""AIS 6-bit armoring and common Class A message decoding."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .models import AisMessage
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _bits(payload: str) -> str:
|
|
8
|
+
values = []
|
|
9
|
+
for char in payload:
|
|
10
|
+
code = ord(char) - 48
|
|
11
|
+
if code > 40:
|
|
12
|
+
code -= 8
|
|
13
|
+
if not 0 <= code < 64:
|
|
14
|
+
raise ValueError("invalid AIS armored character")
|
|
15
|
+
values.append(f"{code:06b}")
|
|
16
|
+
return "".join(values)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _u(bits: str, start: int, width: int) -> int:
|
|
20
|
+
end = start + width
|
|
21
|
+
if end > len(bits):
|
|
22
|
+
raise ValueError("truncated AIS payload")
|
|
23
|
+
return int(bits[start:end], 2)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _signed(bits: str, start: int, width: int) -> int:
|
|
27
|
+
value = _u(bits, start, width)
|
|
28
|
+
return value - (1 << width) if value & (1 << (width - 1)) else value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decode(payload: str, fill_bits: int = 0) -> AisMessage:
|
|
32
|
+
if not 0 <= fill_bits <= 5:
|
|
33
|
+
raise ValueError("fill bits out of range")
|
|
34
|
+
bits = _bits(payload)
|
|
35
|
+
if fill_bits:
|
|
36
|
+
bits = bits[:-fill_bits]
|
|
37
|
+
message_type, repeat, mmsi = _u(bits, 0, 6), _u(bits, 6, 2), _u(bits, 8, 30)
|
|
38
|
+
fields: dict[str, int | float | str] = {}
|
|
39
|
+
if message_type in {1, 2, 3}:
|
|
40
|
+
fields.update(
|
|
41
|
+
navigational_status=_u(bits, 38, 4),
|
|
42
|
+
rate_of_turn=_signed(bits, 42, 8),
|
|
43
|
+
speed_over_ground=_u(bits, 50, 10) / 10.0,
|
|
44
|
+
longitude=_signed(bits, 61, 28) / 600000.0,
|
|
45
|
+
latitude=_signed(bits, 89, 27) / 600000.0,
|
|
46
|
+
course_over_ground=_u(bits, 116, 12) / 10.0,
|
|
47
|
+
true_heading=_u(bits, 128, 9),
|
|
48
|
+
timestamp=_u(bits, 137, 6),
|
|
49
|
+
)
|
|
50
|
+
elif message_type == 5:
|
|
51
|
+
fields.update(
|
|
52
|
+
imo_number=_u(bits, 40, 30),
|
|
53
|
+
callsign=_text(bits, 70, 42),
|
|
54
|
+
vessel_name=_text(bits, 112, 120),
|
|
55
|
+
ship_type=_u(bits, 232, 8),
|
|
56
|
+
to_bow=_u(bits, 240, 9), to_stern=_u(bits, 249, 9),
|
|
57
|
+
to_port=_u(bits, 258, 6), to_starboard=_u(bits, 264, 6),
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
fields["raw_bits"] = bits
|
|
61
|
+
return AisMessage(message_type, repeat, mmsi, fields, payload)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _text(bits: str, start: int, width: int) -> str:
|
|
65
|
+
alphabet = "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_ !\"#$%&'()*+,-./0123456789:;<=>?"
|
|
66
|
+
return "".join(alphabet[_u(bits, offset, 6)] for offset in range(start, start + width, 6)).rstrip(" @")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Small, explicit decoders for NMEA-0180/0182 steering sentences."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def parse_0180(fields: list[str] | tuple[str, ...]) -> dict[str, str | float]:
|
|
6
|
+
if len(fields) < 2:
|
|
7
|
+
raise ValueError("NMEA-0180 requires steering flag and error")
|
|
8
|
+
flag = fields[0].upper()
|
|
9
|
+
if flag not in {"L", "R", "A", "V"}:
|
|
10
|
+
raise ValueError("invalid cross-track error flag")
|
|
11
|
+
return {"cross_track_flag": flag, "cross_track_error": float(fields[1])}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_0182(fields: list[str] | tuple[str, ...]) -> dict[str, str | float]:
|
|
15
|
+
if len(fields) < 2:
|
|
16
|
+
raise ValueError("NMEA-0182 requires bearing flag and bearing")
|
|
17
|
+
flag = fields[0].upper()
|
|
18
|
+
if flag not in {"A", "M", "T", "V"}:
|
|
19
|
+
raise ValueError("invalid bearing flag")
|
|
20
|
+
return {"bearing_flag": flag, "bearing": float(fields[1])}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Public immutable data structures and parser status values."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Status(str, Enum):
|
|
10
|
+
VALID_FRAME = "VALID_FRAME"
|
|
11
|
+
PARTIAL_STREAM = "PARTIAL_STREAM"
|
|
12
|
+
CORRUPTED_FRAME = "CORRUPTED_FRAME"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ParseEvent:
|
|
17
|
+
status: Status
|
|
18
|
+
protocol: str | None = None
|
|
19
|
+
message: Any = None
|
|
20
|
+
error: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class NmeaSentence:
|
|
25
|
+
talker: str
|
|
26
|
+
sentence_type: str
|
|
27
|
+
fields: tuple[str, ...]
|
|
28
|
+
raw: str
|
|
29
|
+
checksum: int | None
|
|
30
|
+
proprietary: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class AisMessage:
|
|
35
|
+
message_type: int
|
|
36
|
+
repeat: int
|
|
37
|
+
mmsi: int
|
|
38
|
+
fields: dict[str, int | float | str]
|
|
39
|
+
payload: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class CanFrame:
|
|
44
|
+
can_id: int
|
|
45
|
+
data: bytes
|
|
46
|
+
timestamp: float
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class N2kMessage:
|
|
51
|
+
priority: int
|
|
52
|
+
pgn: int
|
|
53
|
+
source: int
|
|
54
|
+
destination: int | None
|
|
55
|
+
payload: bytes
|
|
56
|
+
timestamp: float
|
|
57
|
+
fields: dict[str, int | float | str] = field(default_factory=dict)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""NMEA-2000 CAN identifier decoding and bounded Fast-Packet reassembly."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from .models import N2kMessage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def decode_can_id(can_id: int) -> tuple[int, int, int, int | None]:
|
|
11
|
+
if not 0 <= can_id < (1 << 29):
|
|
12
|
+
raise ValueError("CAN identifier must be an 11-bit or 29-bit value")
|
|
13
|
+
priority = (can_id >> 26) & 7
|
|
14
|
+
data_page = (can_id >> 24) & 1
|
|
15
|
+
pdu_format = (can_id >> 16) & 0xFF
|
|
16
|
+
pdu_specific = (can_id >> 8) & 0xFF
|
|
17
|
+
source = can_id & 0xFF
|
|
18
|
+
pgn = (data_page << 16) | (pdu_format << 8)
|
|
19
|
+
destination = None if pdu_format >= 240 else pdu_specific
|
|
20
|
+
if pdu_format >= 240:
|
|
21
|
+
pgn |= pdu_specific
|
|
22
|
+
return priority, pgn, source, destination
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class _Assembly:
|
|
27
|
+
total: int
|
|
28
|
+
data: bytearray
|
|
29
|
+
next_index: int
|
|
30
|
+
last_seen: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FastPacketAssembler:
|
|
34
|
+
def __init__(self, timeout: float = 1.0, max_payload: int = 223) -> None:
|
|
35
|
+
if timeout <= 0 or max_payload < 1:
|
|
36
|
+
raise ValueError("invalid reassembly limits")
|
|
37
|
+
self.timeout, self.max_payload = timeout, max_payload
|
|
38
|
+
self._assemblies: dict[tuple[int, int, int, int | None, int], _Assembly] = {}
|
|
39
|
+
|
|
40
|
+
def purge(self, now: float) -> None:
|
|
41
|
+
expired = [key for key, item in self._assemblies.items() if now - item.last_seen > self.timeout]
|
|
42
|
+
for key in expired:
|
|
43
|
+
del self._assemblies[key]
|
|
44
|
+
|
|
45
|
+
def feed(self, can_id: int, data: bytes, timestamp: float) -> N2kMessage | None:
|
|
46
|
+
if len(data) != 8:
|
|
47
|
+
raise ValueError("N2K CAN data frames must contain exactly 8 bytes")
|
|
48
|
+
self.purge(timestamp)
|
|
49
|
+
priority, pgn, source, destination = decode_can_id(can_id)
|
|
50
|
+
sequence = data[0] >> 5
|
|
51
|
+
frame_number = data[0] & 0x1F
|
|
52
|
+
key = (pgn, source, destination or 0, sequence, priority)
|
|
53
|
+
if frame_number == 0:
|
|
54
|
+
total = data[1]
|
|
55
|
+
if not 1 <= total <= self.max_payload:
|
|
56
|
+
raise ValueError("invalid Fast-Packet payload length")
|
|
57
|
+
item = _Assembly(total, bytearray(data[2:]), 1, timestamp)
|
|
58
|
+
self._assemblies[key] = item
|
|
59
|
+
if total <= 6:
|
|
60
|
+
del self._assemblies[key]
|
|
61
|
+
return N2kMessage(priority, pgn, source, destination, bytes(item.data[:total]), timestamp)
|
|
62
|
+
return None
|
|
63
|
+
item = self._assemblies.get(key)
|
|
64
|
+
if item is None or frame_number != item.next_index:
|
|
65
|
+
self._assemblies.pop(key, None)
|
|
66
|
+
raise ValueError("missing or out-of-order Fast-Packet frame")
|
|
67
|
+
item.data.extend(data[1:])
|
|
68
|
+
item.next_index += 1
|
|
69
|
+
item.last_seen = timestamp
|
|
70
|
+
if len(item.data) >= item.total:
|
|
71
|
+
del self._assemblies[key]
|
|
72
|
+
return N2kMessage(priority, pgn, source, destination, bytes(item.data[:item.total]), timestamp)
|
|
73
|
+
return None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""NMEA-0183 sentence parsing, checksum validation, and coordinate helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from .models import NmeaSentence
|
|
6
|
+
|
|
7
|
+
_SENTENCE = re.compile(r"^([$!])([^,*\r\n]{1,80})(?:,([^*\r\n]{0,512}))?(?:\*([0-9A-Fa-f]{2}))?$", re.ASCII)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def checksum(body: str | bytes) -> int:
|
|
11
|
+
raw = body.encode("ascii") if isinstance(body, str) else body
|
|
12
|
+
value = 0
|
|
13
|
+
for byte in raw:
|
|
14
|
+
value ^= byte
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_sentence(line: str | bytes, *, require_checksum: bool = False) -> NmeaSentence:
|
|
19
|
+
text = line.decode("ascii", "strict") if isinstance(line, bytes) else line
|
|
20
|
+
text = text.strip("\r\n")
|
|
21
|
+
if not text or text[0] not in "$!":
|
|
22
|
+
raise ValueError("sentence must begin with '$' or '!'")
|
|
23
|
+
match = _SENTENCE.fullmatch(text)
|
|
24
|
+
if not match:
|
|
25
|
+
raise ValueError("malformed NMEA sentence")
|
|
26
|
+
prefix, head, fields_text, supplied_hex = match.groups()
|
|
27
|
+
supplied = int(supplied_hex, 16) if supplied_hex else None
|
|
28
|
+
body = head + (("," + fields_text) if fields_text is not None else "")
|
|
29
|
+
if supplied is None and require_checksum:
|
|
30
|
+
raise ValueError("checksum is required")
|
|
31
|
+
if supplied is not None and checksum(body) != supplied:
|
|
32
|
+
raise ValueError("checksum mismatch")
|
|
33
|
+
if head.startswith("P"):
|
|
34
|
+
talker, sentence_type = head[:3], head[3:]
|
|
35
|
+
proprietary = True
|
|
36
|
+
else:
|
|
37
|
+
talker, sentence_type = head[:2], head[2:]
|
|
38
|
+
proprietary = False
|
|
39
|
+
fields = tuple(fields_text.split(",")) if fields_text is not None else ()
|
|
40
|
+
return NmeaSentence(talker, sentence_type, fields, text, supplied, proprietary)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ddm_to_decimal(value: str, hemisphere: str) -> float:
|
|
44
|
+
"""Convert NMEA ddmm.mmmm or dddmm.mmmm coordinates to signed degrees."""
|
|
45
|
+
if not value or hemisphere.upper() not in {"N", "S", "E", "W"}:
|
|
46
|
+
raise ValueError("invalid DDM coordinate")
|
|
47
|
+
try:
|
|
48
|
+
degrees = int(value[: value.index(".") - 2])
|
|
49
|
+
minutes = float(value[value.index(".") - 2 :])
|
|
50
|
+
except (ValueError, IndexError):
|
|
51
|
+
raise ValueError("invalid DDM coordinate") from None
|
|
52
|
+
if not 0 <= minutes < 60:
|
|
53
|
+
raise ValueError("coordinate minutes out of range")
|
|
54
|
+
decimal = degrees + minutes / 60.0
|
|
55
|
+
return -decimal if hemisphere.upper() in {"S", "W"} else decimal
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""NMEA-OneNet/IEC 61162-450 transport envelope helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class OneNetDatagram:
|
|
9
|
+
transport_header: str
|
|
10
|
+
payload: bytes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_datagram(data: bytes | str) -> OneNetDatagram:
|
|
14
|
+
raw = data.encode("ascii") if isinstance(data, str) else data
|
|
15
|
+
separator = raw.find(b" ")
|
|
16
|
+
if separator <= 0 or separator > 128:
|
|
17
|
+
raise ValueError("OneNet transport header is missing")
|
|
18
|
+
header = raw[:separator].decode("ascii", "strict")
|
|
19
|
+
if not (header.startswith("UdP") or header.startswith("TcP")):
|
|
20
|
+
raise ValueError("unsupported OneNet transport header")
|
|
21
|
+
return OneNetDatagram(header, raw[separator + 1 :])
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Bounded, incremental dispatcher for ASCII NMEA and binary CAN inputs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .ais import decode as decode_ais
|
|
5
|
+
from .models import N2kMessage, ParseEvent, Status
|
|
6
|
+
from .n2k import FastPacketAssembler
|
|
7
|
+
from .nmea0183 import parse_sentence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _PartialFrame(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StreamParser:
|
|
15
|
+
def __init__(self, *, max_sentence: int = 1024, n2k_timeout: float = 1.0) -> None:
|
|
16
|
+
if max_sentence < 16:
|
|
17
|
+
raise ValueError("max_sentence is too small")
|
|
18
|
+
self.max_sentence = max_sentence
|
|
19
|
+
self._text = bytearray()
|
|
20
|
+
self._ais_fragments: dict[str, list[str]] = {}
|
|
21
|
+
self.n2k = FastPacketAssembler(timeout=n2k_timeout)
|
|
22
|
+
|
|
23
|
+
def feed(self, chunk: str | bytes) -> list[ParseEvent]:
|
|
24
|
+
raw = chunk.encode("ascii") if isinstance(chunk, str) else bytes(chunk)
|
|
25
|
+
events: list[ParseEvent] = []
|
|
26
|
+
for byte in raw:
|
|
27
|
+
if byte in (10, 13):
|
|
28
|
+
if self._text:
|
|
29
|
+
events.append(self._parse_line(bytes(self._text)))
|
|
30
|
+
self._text.clear()
|
|
31
|
+
continue
|
|
32
|
+
if not self._text and byte not in (36, 33):
|
|
33
|
+
continue
|
|
34
|
+
if len(self._text) >= self.max_sentence:
|
|
35
|
+
self._text.clear()
|
|
36
|
+
events.append(ParseEvent(Status.CORRUPTED_FRAME, error="sentence exceeds configured limit"))
|
|
37
|
+
continue
|
|
38
|
+
self._text.append(byte)
|
|
39
|
+
if self._text:
|
|
40
|
+
events.append(ParseEvent(Status.PARTIAL_STREAM))
|
|
41
|
+
elif not events:
|
|
42
|
+
events.append(ParseEvent(Status.PARTIAL_STREAM))
|
|
43
|
+
return events
|
|
44
|
+
|
|
45
|
+
def feed_can(self, can_id: int, data: bytes, timestamp: float) -> ParseEvent:
|
|
46
|
+
try:
|
|
47
|
+
message = self.n2k.feed(can_id, data, timestamp)
|
|
48
|
+
except (ValueError, IndexError) as exc:
|
|
49
|
+
return ParseEvent(Status.CORRUPTED_FRAME, protocol="NMEA-2000", error=str(exc))
|
|
50
|
+
if message is None:
|
|
51
|
+
return ParseEvent(Status.PARTIAL_STREAM, protocol="NMEA-2000")
|
|
52
|
+
return ParseEvent(Status.VALID_FRAME, protocol="NMEA-2000", message=message)
|
|
53
|
+
|
|
54
|
+
def _parse_line(self, raw: bytes) -> ParseEvent:
|
|
55
|
+
try:
|
|
56
|
+
sentence = parse_sentence(raw, require_checksum=False)
|
|
57
|
+
if sentence.talker == "AI" and sentence.sentence_type in {"VDM", "VDO"}:
|
|
58
|
+
message = self._parse_ais(sentence)
|
|
59
|
+
return ParseEvent(Status.VALID_FRAME, protocol="AIS", message=message)
|
|
60
|
+
return ParseEvent(Status.VALID_FRAME, protocol="NMEA-0183", message=sentence)
|
|
61
|
+
except _PartialFrame:
|
|
62
|
+
return ParseEvent(Status.PARTIAL_STREAM, protocol="AIS")
|
|
63
|
+
except (ValueError, UnicodeError) as exc:
|
|
64
|
+
return ParseEvent(Status.CORRUPTED_FRAME, protocol="NMEA-0183", error=str(exc))
|
|
65
|
+
|
|
66
|
+
def _parse_ais(self, sentence):
|
|
67
|
+
if len(sentence.fields) < 6:
|
|
68
|
+
raise ValueError("AIS sentence requires fragment fields")
|
|
69
|
+
total, number = int(sentence.fields[0]), int(sentence.fields[1])
|
|
70
|
+
sequence = sentence.fields[2] or "_"
|
|
71
|
+
payload, fill = sentence.fields[4], int(sentence.fields[5])
|
|
72
|
+
key = f"{sentence.sentence_type}:{sequence}"
|
|
73
|
+
parts = self._ais_fragments.setdefault(key, [])
|
|
74
|
+
if number == 1:
|
|
75
|
+
parts.clear()
|
|
76
|
+
if number != len(parts) + 1 or total < number:
|
|
77
|
+
self._ais_fragments.pop(key, None)
|
|
78
|
+
raise ValueError("AIS fragments are out of order")
|
|
79
|
+
parts.append(payload)
|
|
80
|
+
if number != total:
|
|
81
|
+
raise _PartialFrame()
|
|
82
|
+
self._ais_fragments.pop(key, None)
|
|
83
|
+
return decode_ais("".join(parts), fill)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Registry hook for vendor-specific NMEA-0183 $P sentences."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from .models import NmeaSentence
|
|
6
|
+
|
|
7
|
+
Decoder = Callable[[NmeaSentence], object]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProprietaryRegistry:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._decoders: dict[str, Decoder] = {}
|
|
13
|
+
|
|
14
|
+
def register(self, manufacturer: str, decoder: Decoder) -> None:
|
|
15
|
+
if not manufacturer or not manufacturer.isalnum():
|
|
16
|
+
raise ValueError("manufacturer key must be alphanumeric")
|
|
17
|
+
self._decoders[manufacturer.upper()] = decoder
|
|
18
|
+
|
|
19
|
+
def decode(self, sentence: NmeaSentence) -> object:
|
|
20
|
+
if not sentence.proprietary:
|
|
21
|
+
raise ValueError("sentence is not proprietary")
|
|
22
|
+
decoder = self._decoders.get(sentence.talker[1:].upper())
|
|
23
|
+
return decoder(sentence) if decoder else sentence
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maritime-frame
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency streaming parsers for NMEA and related marine telemetry protocols
|
|
5
|
+
Author: Maritime Frame Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Topic :: System :: Hardware
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# maritime-frame
|
|
15
|
+
|
|
16
|
+
`maritime-frame` is a zero-runtime-dependency Python library for incremental NMEA and marine telemetry decoding. It is designed for applications that receive arbitrary serial, TCP, UDP, or CAN fragments and cannot assume that one read equals one frame.
|
|
17
|
+
|
|
18
|
+
## Install and test
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m pip install .
|
|
22
|
+
python -m unittest discover -s tests -v
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Streaming NMEA-0183 and AIS
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from maritime_frame import StreamParser, Status
|
|
29
|
+
|
|
30
|
+
parser = StreamParser(max_sentence=1024)
|
|
31
|
+
for chunk in serial_port:
|
|
32
|
+
for event in parser.feed(chunk):
|
|
33
|
+
if event.status is Status.VALID_FRAME:
|
|
34
|
+
print(event.protocol, event.message)
|
|
35
|
+
elif event.status is Status.CORRUPTED_FRAME:
|
|
36
|
+
logger.warning("discarded frame: %s", event.error)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`feed()` accepts `str` or ASCII `bytes`, retains incomplete lines, rejects overlong input, validates the optional two-digit XOR checksum, and recognizes `$` and `!` prefixes. `ddm_to_decimal("4807.038", "N")` returns `48.1173`. AIS `!AIVDM` and `!AIVDO` payloads are 6-bit unarmored into a bit string; message types 1, 2, 3, and 5 expose MMSI, position, speed, course, heading, identity, and ship dimensions. Multi-sentence AIS messages are reassembled by sequence ID.
|
|
40
|
+
|
|
41
|
+
## NMEA-2000 CAN and Fast Packet
|
|
42
|
+
|
|
43
|
+
A 29-bit CAN identifier is mapped as follows:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
28 26 25 24 23 16 15 8 7 0
|
|
47
|
+
+----------+--+--+-----------+------------+---------+
|
|
48
|
+
| priority |R |DP| PDU format|PDU specific| source |
|
|
49
|
+
+----------+--+--+-----------+------------+---------+
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from maritime_frame import StreamParser
|
|
54
|
+
|
|
55
|
+
parser = StreamParser(n2k_timeout=1.0)
|
|
56
|
+
first = parser.feed_can(0x0CF00501, bytes([0, 10, 1, 2, 3, 4, 5, 6]), 100.0)
|
|
57
|
+
second = parser.feed_can(0x0CF00501, bytes([1, 7, 8, 9, 10, 0, 0, 0]), 100.1)
|
|
58
|
+
assert second.message.payload == bytes(range(1, 11))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The assembler enforces frame number order, a maximum payload of 223 bytes, exact eight-byte CAN data frames, and timeout purging before each frame. Dropped or late follow-up frames never remain in memory indefinitely.
|
|
62
|
+
|
|
63
|
+
## OneNet, legacy, and proprietary layers
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from maritime_frame.onenet import parse_datagram
|
|
67
|
+
from maritime_frame.proprietary import ProprietaryRegistry
|
|
68
|
+
|
|
69
|
+
network = parse_datagram(b"UdPBc $GPRMC,...")
|
|
70
|
+
registry = ProprietaryRegistry()
|
|
71
|
+
registry.register("PGR", lambda sentence: {"vendor": "Garmin", "fields": sentence.fields})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`parse_datagram` validates the `UdP*`/`TcP*` transport token and preserves the payload for the normal stream parser. `legacy.parse_0180` and `legacy.parse_0182` decode steering and bearing flags. The proprietary registry is intentionally open-ended so vendor payload schemas remain application-owned.
|
|
75
|
+
|
|
76
|
+
## Status contract
|
|
77
|
+
|
|
78
|
+
Every chunk or CAN frame yields one deterministic status: `VALID_FRAME`, `PARTIAL_STREAM`, or `CORRUPTED_FRAME`. A partial NMEA line produces a partial event while its bytes remain buffered. Invalid checksums, invalid AIS armor, overflows, malformed transport headers, and N2K sequencing errors produce corrupted events without exposing unsafe indexes or unbounded allocations.
|
|
79
|
+
|
|
80
|
+
## Scope and transport boundary
|
|
81
|
+
|
|
82
|
+
The package parses frames; it does not open sockets, configure serial ports, or control a CAN adapter. That keeps it portable and lets downstream systems choose their I/O, scheduling, timestamp, and multicast policy. All runtime code uses only the Python standard library.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
src/maritime_frame/__init__.py
|
|
6
|
+
src/maritime_frame/ais.py
|
|
7
|
+
src/maritime_frame/legacy.py
|
|
8
|
+
src/maritime_frame/models.py
|
|
9
|
+
src/maritime_frame/n2k.py
|
|
10
|
+
src/maritime_frame/nmea0183.py
|
|
11
|
+
src/maritime_frame/onenet.py
|
|
12
|
+
src/maritime_frame/parser.py
|
|
13
|
+
src/maritime_frame/proprietary.py
|
|
14
|
+
src/maritime_frame.egg-info/PKG-INFO
|
|
15
|
+
src/maritime_frame.egg-info/SOURCES.txt
|
|
16
|
+
src/maritime_frame.egg-info/dependency_links.txt
|
|
17
|
+
src/maritime_frame.egg-info/top_level.txt
|
|
18
|
+
tests/test_parser.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
maritime_frame
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from maritime_frame import FastPacketAssembler, Status, StreamParser, ddm_to_decimal, parse_sentence
|
|
4
|
+
from maritime_frame.ais import decode as decode_ais
|
|
5
|
+
from maritime_frame.n2k import decode_can_id
|
|
6
|
+
from maritime_frame.onenet import parse_datagram
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ParserTests(unittest.TestCase):
|
|
10
|
+
def test_sentence_checksum_and_ddm(self):
|
|
11
|
+
sentence = "$GPGGA,123519,4807.038,N,01131.000,E,1*53"
|
|
12
|
+
parsed = parse_sentence(sentence, require_checksum=True)
|
|
13
|
+
self.assertEqual(parsed.sentence_type, "GGA")
|
|
14
|
+
self.assertAlmostEqual(ddm_to_decimal("4807.038", "N"), 48.1173, places=4)
|
|
15
|
+
self.assertAlmostEqual(ddm_to_decimal("01131.000", "W"), -11.516666, places=4)
|
|
16
|
+
|
|
17
|
+
def test_stream_fragment_status_and_recovery(self):
|
|
18
|
+
parser = StreamParser()
|
|
19
|
+
self.assertEqual(parser.feed("$GPRMC,1,2")[0].status, Status.PARTIAL_STREAM)
|
|
20
|
+
event = parser.feed(",3*00\r\n")[0]
|
|
21
|
+
self.assertEqual(event.status, Status.CORRUPTED_FRAME)
|
|
22
|
+
|
|
23
|
+
def test_can_id(self):
|
|
24
|
+
priority, pgn, source, destination = decode_can_id(0x0CF00401)
|
|
25
|
+
self.assertEqual((priority, pgn, source, destination), (3, 61444, 1, None))
|
|
26
|
+
|
|
27
|
+
def test_fast_packet_reassembly(self):
|
|
28
|
+
assembler = FastPacketAssembler(timeout=1.0)
|
|
29
|
+
can_id = 0x0CF00501
|
|
30
|
+
self.assertIsNone(assembler.feed(can_id, bytes([0, 10, 1, 2, 3, 4, 5, 6]), 0.0))
|
|
31
|
+
result = assembler.feed(can_id, bytes([1, 7, 8, 9, 10, 0, 0, 0]), 0.1)
|
|
32
|
+
self.assertEqual(result.payload, bytes(range(1, 11)))
|
|
33
|
+
self.assertIsNone(assembler.feed(can_id, bytes([0, 10, 1, 2, 3, 4, 5, 6]), 1.0))
|
|
34
|
+
with self.assertRaises(ValueError):
|
|
35
|
+
assembler.feed(can_id, bytes([1, 7, 8, 9, 10, 0, 0, 0]), 2.1)
|
|
36
|
+
|
|
37
|
+
def test_onenet(self):
|
|
38
|
+
datagram = parse_datagram(b"UdPBc 239.0.0.1\r\n$GPRMC")
|
|
39
|
+
self.assertEqual(datagram.transport_header, "UdPBc")
|
|
40
|
+
self.assertTrue(datagram.payload.startswith(b"239"))
|
|
41
|
+
|
|
42
|
+
def test_unknown_ais_type_is_decoded(self):
|
|
43
|
+
message = decode_ais("0" * 20)
|
|
44
|
+
self.assertEqual(message.message_type, 0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
unittest.main()
|