Files
venus/axiom-nmea/raymarine_nmea/protocol/parser.py
dev 9756538f16 Initial commit: Venus OS boat addons monorepo
Organizes 11 projects for Cerbo GX/Venus OS into a single repository:
- axiom-nmea: Raymarine LightHouse protocol decoder
- dbus-generator-ramp: Generator current ramp controller
- dbus-lightning: Blitzortung lightning monitor
- dbus-meteoblue-forecast: Meteoblue weather forecast
- dbus-no-foreign-land: noforeignland.com tracking
- dbus-tides: Tide prediction from depth + harmonics
- dbus-vrm-history: VRM cloud history proxy
- dbus-windy-station: Windy.com weather upload
- mfd-custom-app: MFD app deployment package
- venus-html5-app: Custom Victron HTML5 app fork
- watermaker: Watermaker PLC control UI

Adds root README, .gitignore, project template, and per-project
.gitignore files. Sensitive config files excluded via .gitignore
with .example templates provided.

Made-with: Cursor
2026-03-16 17:04:16 +00:00

244 lines
7.1 KiB
Python

"""
Protobuf wire format parser for Raymarine packets.
This parser implements the Google Protocol Buffers wire format without
requiring a schema (.proto file). It can parse any protobuf message and
return the field structure.
Wire Format Reference:
https://developers.google.com/protocol-buffers/docs/encoding
"""
import struct
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Set
from .constants import (
WIRE_VARINT,
WIRE_FIXED64,
WIRE_LENGTH,
WIRE_FIXED32,
)
@dataclass
class ProtoField:
"""A decoded protobuf field.
Attributes:
field_num: The field number (1-536870911)
wire_type: The wire type (0, 1, 2, or 5)
value: The decoded value (int, bytes, or float)
children: For length-delimited fields, nested message fields
"""
field_num: int
wire_type: int
value: Any
children: Dict[int, 'ProtoField'] = field(default_factory=dict)
class ProtobufParser:
"""Parses protobuf wire format without a schema.
This parser reads raw protobuf data and extracts fields based on their
wire type. For length-delimited fields, it attempts to parse them as
nested messages.
Example:
parser = ProtobufParser(packet_bytes)
fields = parser.parse_message(collect_repeated={14, 16, 20})
if 2 in fields:
gps_field = fields[2]
if gps_field.children:
# Access nested GPS fields
lat_field = gps_field.children.get(1)
"""
def __init__(self, data: bytes):
"""Initialize parser with protobuf data.
Args:
data: Raw protobuf bytes to parse
"""
self.data = data
self.pos = 0
def remaining(self) -> int:
"""Return number of unread bytes."""
return len(self.data) - self.pos
def read_varint(self) -> int:
"""Decode a variable-length integer.
Varints use 7 bits per byte for the value, with the high bit
indicating whether more bytes follow.
Returns:
The decoded integer value
Raises:
IndexError: If data ends before varint is complete
"""
result = 0
shift = 0
while self.pos < len(self.data):
byte = self.data[self.pos]
self.pos += 1
result |= (byte & 0x7F) << shift
if not (byte & 0x80):
break
shift += 7
if shift > 63:
break
return result
def read_fixed64(self) -> bytes:
"""Read 8 bytes (fixed64 wire type).
Returns:
8 bytes of raw data
"""
value = self.data[self.pos:self.pos + 8]
self.pos += 8
return value
def read_fixed32(self) -> bytes:
"""Read 4 bytes (fixed32 wire type).
Returns:
4 bytes of raw data
"""
value = self.data[self.pos:self.pos + 4]
self.pos += 4
return value
def read_length_delimited(self) -> bytes:
"""Read length-prefixed data.
First reads a varint for the length, then reads that many bytes.
Returns:
The length-delimited data
"""
length = self.read_varint()
value = self.data[self.pos:self.pos + length]
self.pos += length
return value
def parse_message(
self,
collect_repeated: Optional[Set[int]] = None
) -> Dict[int, Any]:
"""Parse all fields in a protobuf message.
Args:
collect_repeated: Set of field numbers to collect as lists.
Use this for repeated fields like tanks, batteries.
Returns:
Dictionary mapping field numbers to ProtoField objects.
For repeated fields (in collect_repeated), maps to list of ProtoField.
"""
fields: Dict[int, Any] = {}
if collect_repeated is None:
collect_repeated = set()
while self.pos < len(self.data):
if self.remaining() < 1:
break
try:
# Read tag: (field_number << 3) | wire_type
tag = self.read_varint()
field_num = tag >> 3
wire_type = tag & 0x07
# Validate field number
if field_num == 0 or field_num > 536870911:
break
# Read value based on wire type
if wire_type == WIRE_VARINT:
value = self.read_varint()
elif wire_type == WIRE_FIXED64:
if self.remaining() < 8:
break
value = self.read_fixed64()
elif wire_type == WIRE_LENGTH:
value = self.read_length_delimited()
elif wire_type == WIRE_FIXED32:
if self.remaining() < 4:
break
value = self.read_fixed32()
else:
# Unknown wire type (3, 4 are deprecated)
break
# For length-delimited, try to parse as nested message
children: Dict[int, ProtoField] = {}
if wire_type == WIRE_LENGTH and len(value) >= 2:
try:
nested_parser = ProtobufParser(value)
children = nested_parser.parse_message()
# Only keep if we parsed most of the data
if nested_parser.pos < len(value) * 0.5:
children = {}
except Exception:
children = {}
pf = ProtoField(field_num, wire_type, value, children)
# Handle repeated fields - collect as list
if field_num in collect_repeated:
if field_num not in fields:
fields[field_num] = []
fields[field_num].append(pf)
else:
# Keep last occurrence for non-repeated fields
fields[field_num] = pf
except (IndexError, struct.error):
break
return fields
@staticmethod
def decode_double(raw: bytes) -> Optional[float]:
"""Decode 8 bytes as little-endian double.
Args:
raw: 8 bytes of raw data
Returns:
Decoded float value, or None if invalid/NaN
"""
if len(raw) != 8:
return None
try:
val = struct.unpack('<d', raw)[0]
if val != val: # NaN check
return None
return val
except struct.error:
return None
@staticmethod
def decode_float(raw: bytes) -> Optional[float]:
"""Decode 4 bytes as little-endian float.
Args:
raw: 4 bytes of raw data
Returns:
Decoded float value, or None if invalid/NaN
"""
if len(raw) != 4:
return None
try:
val = struct.unpack('<f', raw)[0]
if val != val: # NaN check
return None
return val
except struct.error:
return None