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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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()