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
332 lines
11 KiB
Python
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)
|