Files
venus/axiom-nmea/debug/protobuf_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

332 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Proper protobuf wire format parser for Raymarine packets.
Decodes the nested message structure to understand the protocol.
"""
import struct
from dataclasses import dataclass
from typing import List, Tuple, Optional, Any
# Wire types
WIRE_VARINT = 0 # int32, int64, uint32, uint64, sint32, sint64, bool, enum
WIRE_FIXED64 = 1 # fixed64, sfixed64, double
WIRE_LENGTH = 2 # string, bytes, embedded messages, packed repeated fields
WIRE_FIXED32 = 5 # fixed32, sfixed32, float
WIRE_NAMES = {
0: 'varint',
1: 'fixed64',
2: 'length-delim',
5: 'fixed32',
}
@dataclass
class ProtoField:
"""Represents a decoded protobuf field."""
field_num: int
wire_type: int
offset: int
length: int
raw_value: bytes
decoded_value: Any = None
children: List['ProtoField'] = None
def __post_init__(self):
if self.children is None:
self.children = []
class ProtobufDecoder:
"""Decodes protobuf wire format without a schema."""
def __init__(self, data: bytes):
self.data = data
self.pos = 0
def decode_varint(self) -> Tuple[int, int]:
"""Decode a varint, return (value, bytes_consumed)."""
result = 0
shift = 0
start = self.pos
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, self.pos - start
def decode_fixed64(self) -> bytes:
"""Decode 8 bytes (fixed64/double)."""
value = self.data[self.pos:self.pos + 8]
self.pos += 8
return value
def decode_fixed32(self) -> bytes:
"""Decode 4 bytes (fixed32/float)."""
value = self.data[self.pos:self.pos + 4]
self.pos += 4
return value
def decode_length_delimited(self) -> bytes:
"""Decode length-delimited field (string, bytes, nested message)."""
length, _ = self.decode_varint()
value = self.data[self.pos:self.pos + length]
self.pos += length
return value
def decode_field(self) -> Optional[ProtoField]:
"""Decode a single protobuf field."""
if self.pos >= len(self.data):
return None
start_offset = self.pos
# Decode tag
tag, _ = self.decode_varint()
field_num = tag >> 3
wire_type = tag & 0x07
# Sanity check
if field_num == 0 or field_num > 536870911: # Max field number
return None
try:
if wire_type == WIRE_VARINT:
value, _ = self.decode_varint()
raw = self.data[start_offset:self.pos]
return ProtoField(field_num, wire_type, start_offset,
self.pos - start_offset, raw, value)
elif wire_type == WIRE_FIXED64:
raw = self.decode_fixed64()
# Try to decode as double
try:
double_val = struct.unpack('<d', raw)[0]
decoded = double_val
except:
decoded = raw
return ProtoField(field_num, wire_type, start_offset,
self.pos - start_offset, raw, decoded)
elif wire_type == WIRE_LENGTH:
raw = self.decode_length_delimited()
return ProtoField(field_num, wire_type, start_offset,
self.pos - start_offset, raw, raw)
elif wire_type == WIRE_FIXED32:
raw = self.decode_fixed32()
# Try to decode as float
try:
float_val = struct.unpack('<f', raw)[0]
decoded = float_val
except:
decoded = raw
return ProtoField(field_num, wire_type, start_offset,
self.pos - start_offset, raw, decoded)
else:
# Unknown wire type
return None
except (IndexError, struct.error):
return None
def decode_all(self) -> List[ProtoField]:
"""Decode all fields in the buffer."""
fields = []
while self.pos < len(self.data):
field = self.decode_field()
if field is None:
break
fields.append(field)
return fields
def try_decode_nested(field: ProtoField, depth: int = 0, max_depth: int = 5) -> bool:
"""Try to decode a length-delimited field as a nested message."""
if field.wire_type != WIRE_LENGTH or depth >= max_depth:
return False
if len(field.raw_value) < 2:
return False
# Try to decode as nested protobuf
decoder = ProtobufDecoder(field.raw_value)
children = []
try:
while decoder.pos < len(decoder.data):
child = decoder.decode_field()
if child is None:
break
# Recursively try to decode nested messages
if child.wire_type == WIRE_LENGTH:
try_decode_nested(child, depth + 1, max_depth)
children.append(child)
# Only consider it a valid nested message if we decoded most of the data
if children and decoder.pos >= len(decoder.data) * 0.8:
field.children = children
return True
except:
pass
return False
def print_field(field: ProtoField, indent: int = 0, show_raw: bool = False):
"""Pretty print a protobuf field."""
prefix = " " * indent
wire_name = WIRE_NAMES.get(field.wire_type, f'unknown({field.wire_type})')
# Format value based on type
if field.wire_type == WIRE_VARINT:
value_str = f"{field.decoded_value}"
elif field.wire_type == WIRE_FIXED64:
if isinstance(field.decoded_value, float):
value_str = f"{field.decoded_value:.6f}"
# Check if it could be coordinates
if -90 <= field.decoded_value <= 90:
value_str += " (could be lat)"
elif -180 <= field.decoded_value <= 180:
value_str += " (could be lon)"
else:
value_str = field.raw_value.hex()
elif field.wire_type == WIRE_FIXED32:
if isinstance(field.decoded_value, float):
value_str = f"{field.decoded_value:.4f}"
# Check if it could be angle in radians
if 0 <= field.decoded_value <= 6.5:
deg = field.decoded_value * 57.2958
value_str += f" ({deg:.1f}°)"
else:
value_str = field.raw_value.hex()
elif field.wire_type == WIRE_LENGTH:
if field.children:
value_str = f"[nested message, {len(field.children)} fields]"
else:
# Try to show as string if printable
try:
text = field.raw_value.decode('ascii')
if all(32 <= ord(c) < 127 or c in '\n\r\t' for c in text):
value_str = f'"{text}"'
else:
value_str = f"[{len(field.raw_value)} bytes]"
except:
value_str = f"[{len(field.raw_value)} bytes]"
else:
value_str = field.raw_value.hex()
print(f"{prefix}field {field.field_num:2d} ({wire_name:12s}) @ 0x{field.offset:04x}: {value_str}")
if show_raw and field.wire_type == WIRE_LENGTH and not field.children:
# Show hex dump for non-nested length-delimited fields
hex_str = ' '.join(f'{b:02x}' for b in field.raw_value[:32])
if len(field.raw_value) > 32:
hex_str += ' ...'
print(f"{prefix} raw: {hex_str}")
# Print children
for child in field.children:
print_field(child, indent + 1, show_raw)
def analyze_packet(data: bytes, show_raw: bool = False):
"""Analyze a single packet."""
print(f"\nPacket length: {len(data)} bytes")
# Check for fixed header
if len(data) > 20:
header = data[:20]
print(f"Header (first 20 bytes): {header.hex()}")
# Look for protobuf start (usually 0x0a = field 1, length-delimited)
proto_start = None
for i in range(0, min(24, len(data))):
if data[i] == 0x0a: # field 1, wire type 2
proto_start = i
break
if proto_start is not None:
print(f"Protobuf likely starts at offset 0x{proto_start:04x}")
proto_data = data[proto_start:]
else:
print("No clear protobuf start found, trying from offset 0")
proto_data = data
else:
proto_data = data
# Decode protobuf
decoder = ProtobufDecoder(proto_data)
fields = decoder.decode_all()
print(f"\nDecoded {len(fields)} top-level fields:")
print("-" * 60)
for field in fields:
# Try to decode nested messages
if field.wire_type == WIRE_LENGTH:
try_decode_nested(field)
print_field(field, show_raw=show_raw)
def read_pcap(filename):
"""Read packets from pcap file."""
packets = []
with open(filename, 'rb') as f:
header = f.read(24)
magic = struct.unpack('<I', header[0:4])[0]
swapped = magic == 0xd4c3b2a1
endian = '>' if swapped else '<'
while True:
pkt_header = f.read(16)
if len(pkt_header) < 16:
break
ts_sec, ts_usec, incl_len, orig_len = struct.unpack(f'{endian}IIII', pkt_header)
pkt_data = f.read(incl_len)
if len(pkt_data) < incl_len:
break
if len(pkt_data) > 42 and pkt_data[12:14] == b'\x08\x00':
ip_header_len = (pkt_data[14] & 0x0F) * 4
payload_start = 14 + ip_header_len + 8
if payload_start < len(pkt_data):
packets.append(pkt_data[payload_start:])
return packets
if __name__ == "__main__":
import sys
pcap_file = sys.argv[1] if len(sys.argv) > 1 else "raymarine_sample_TWD_62-70_HDG_29-35.pcap"
print(f"Reading {pcap_file}...")
packets = read_pcap(pcap_file)
print(f"Loaded {len(packets)} packets")
# Group by size
by_size = {}
for pkt in packets:
pkt_len = len(pkt)
if pkt_len not in by_size:
by_size[pkt_len] = []
by_size[pkt_len].append(pkt)
# Analyze one packet of each key size
target_sizes = [344, 446, 788, 888, 1472]
for size in target_sizes:
if size in by_size:
print("\n" + "=" * 70)
print(f"ANALYZING {size}-BYTE PACKET")
print("=" * 70)
analyze_packet(by_size[size][0], show_raw=True)