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
244 lines
7.1 KiB
Python
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
|