Keep the colleague's microbu-esp32c5 tree in this repository
obu-firmware builds against vanetza-idf from microbu-esp32c5/external, but that tree was gitignored, so a clone of this repository could not build the firmware it ships. It is now committed here as ordinary files in its own folder, microbu-esp32c5/: the colleague's commit cf4b99f plus the V2X2MAP bridge's signature verification (--trust) used on the bench. Nothing is fetched from or pushed to the colleague's repository; this repository and its remotes carry everything. The folder's own .gitignore keeps build output, downloaded components and private key material out, as it did there; the committed file set is identical to that repository's tracked files. The ESP32-C5 is still flashed from obu-firmware/, which only takes vanetza-idf from microbu-esp32c5/, so the two stay separate folders. FLASHING.md says how to take a newer version of the colleague's tree (copy it over the folder, rebuild, test, commit).
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
"""Message layer version 1 of the micrOBU station-internal link.
|
||||
|
||||
Mirror of the firmware's ``link_protocol.hpp``; the two are checked against each other by
|
||||
``tests/test_station_link.py``. All integers little-endian; ETSI payloads opaque.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
MAXIMUM_MESSAGE = 512
|
||||
HEADER_SIZE = 4
|
||||
|
||||
|
||||
class Opcode(enum.IntEnum):
|
||||
STATION_CONFIGURE = 0x01
|
||||
POTI_UPDATE = 0x02
|
||||
BTP_DATA_REQUEST = 0x03
|
||||
CREDENTIALS_PROVISION = 0x04
|
||||
CREDENTIALS_ERASE = 0x05
|
||||
SF_IDCHANGE_SUBSCRIBE = 0x06
|
||||
SF_IDCHANGE_UNSUBSCRIBE = 0x07
|
||||
SF_IDCHANGE_EVENT_RESPONSE = 0x08
|
||||
SF_IDCHANGE_TRIGGER = 0x09
|
||||
SF_ID_LOCK = 0x0A
|
||||
SF_ID_UNLOCK = 0x0B
|
||||
STATUS_REQUEST = 0x0C
|
||||
RESULT = 0x80
|
||||
BTP_DATA_INDICATION = 0x81
|
||||
SF_IDCHANGE_EVENT = 0x82
|
||||
MF_SET_REQUEST = 0x83
|
||||
STATUS = 0x84
|
||||
|
||||
|
||||
class Code(enum.IntEnum):
|
||||
"""RESULT codes: vanetza_idf::Result first, then link codes."""
|
||||
accepted = 0
|
||||
invalid_argument = 1
|
||||
unsupported = 2
|
||||
wrong_entry_point = 3
|
||||
security_unavailable = 4
|
||||
resource_limit = 5
|
||||
rejected = 6
|
||||
time_regression = 7
|
||||
identity_change_pending = 8
|
||||
unknown_opcode = 0x10
|
||||
malformed = 0x11
|
||||
not_configured = 0x12
|
||||
busy = 0x13
|
||||
no_credentials = 0x14
|
||||
|
||||
|
||||
FLAG_FRAGMENT_FIRST = 0x01
|
||||
FLAG_FRAGMENT_MORE = 0x02
|
||||
|
||||
|
||||
class IdChangeCommand(enum.IntEnum):
|
||||
PREPARE = 0
|
||||
COMMIT = 1
|
||||
ABORT = 2
|
||||
DEREG = 3
|
||||
|
||||
|
||||
class TransportType(enum.IntEnum):
|
||||
SHB = 1
|
||||
GBC = 2
|
||||
TSB = 3
|
||||
GAC = 4
|
||||
GUC = 5
|
||||
|
||||
|
||||
VERIFICATION_REPORTS = ['unsecured', 'Success', 'False_Signature', 'Invalid_Certificate', 'Revoked_Certificate',
|
||||
'Inconsistent_Chain', 'Invalid_Timestamp', 'Duplicate_Message', 'Invalid_Mobility_Data',
|
||||
'Unsigned_Message', 'Signer_Certificate_Not_Found', 'Unsupported_Signer_Identifier_Type',
|
||||
'Incompatible_Protocol', 'Configuration_Problem']
|
||||
|
||||
|
||||
class DecodeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, data: bytes, at: int = 0):
|
||||
self.data = data
|
||||
self.at = at
|
||||
|
||||
def _take(self, n: int) -> bytes:
|
||||
if self.at + n > len(self.data):
|
||||
raise DecodeError('truncated body')
|
||||
chunk = self.data[self.at:self.at + n]
|
||||
self.at += n
|
||||
return chunk
|
||||
|
||||
def u8(self) -> int: return self._take(1)[0]
|
||||
def u16(self) -> int: return struct.unpack('<H', self._take(2))[0]
|
||||
def u32(self) -> int: return struct.unpack('<I', self._take(4))[0]
|
||||
def u64(self) -> int: return struct.unpack('<Q', self._take(8))[0]
|
||||
def i32(self) -> int: return struct.unpack('<i', self._take(4))[0]
|
||||
def bytes(self, n: int) -> bytes: return self._take(n)
|
||||
def rest(self) -> bytes:
|
||||
chunk = self.data[self.at:]
|
||||
self.at = len(self.data)
|
||||
return chunk
|
||||
|
||||
def done(self):
|
||||
if self.at != len(self.data):
|
||||
raise DecodeError('trailing octets')
|
||||
|
||||
|
||||
@dataclass
|
||||
class Header:
|
||||
opcode: int
|
||||
flags: int = 0
|
||||
sequence: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
header: Header
|
||||
body: bytes = b''
|
||||
|
||||
|
||||
def encode_message(message: Message) -> bytes:
|
||||
if HEADER_SIZE + len(message.body) > MAXIMUM_MESSAGE:
|
||||
raise ValueError('message exceeds %d octets' % MAXIMUM_MESSAGE)
|
||||
return struct.pack('<BBH', int(message.header.opcode), message.header.flags, message.header.sequence) + bytes(message.body)
|
||||
|
||||
|
||||
def decode_message(octets: bytes) -> Message:
|
||||
if len(octets) < HEADER_SIZE or len(octets) > MAXIMUM_MESSAGE:
|
||||
raise DecodeError('bad message length %d' % len(octets))
|
||||
opcode, flags, sequence = struct.unpack('<BBH', octets[:HEADER_SIZE])
|
||||
return Message(Header(opcode, flags, sequence), bytes(octets[HEADER_SIZE:]))
|
||||
|
||||
|
||||
# ---- bodies ----------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class StationConfigure:
|
||||
station_type: int = 2
|
||||
security: int = 1
|
||||
address_configuration: int = 1
|
||||
mid: bytes = b'\x02\x00\x00\x00\x00\x01'
|
||||
beaconing: int = 1
|
||||
channel_number: int = 180
|
||||
transmit_power_dbm: int = 10
|
||||
radio: int = 0
|
||||
default_traffic_class: int = 2
|
||||
default_lifetime: int = 0x05
|
||||
|
||||
def encode(self) -> bytes:
|
||||
assert len(self.mid) == 6
|
||||
return struct.pack('<BBB6sBHBBBB', self.station_type, self.security, self.address_configuration, self.mid,
|
||||
self.beaconing, self.channel_number, self.transmit_power_dbm, self.radio,
|
||||
self.default_traffic_class, self.default_lifetime)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, body: bytes) -> 'StationConfigure':
|
||||
if len(body) != 16:
|
||||
raise DecodeError('StationConfigure length')
|
||||
f = struct.unpack('<BBB6sBHBBBB', body)
|
||||
return cls(*f)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StationInfo:
|
||||
"""RESULT detail of STATION_CONFIGURE."""
|
||||
gn_address: bytes
|
||||
identifier: bytes
|
||||
credentials_loaded: bool
|
||||
tickets: int
|
||||
|
||||
@classmethod
|
||||
def decode(cls, detail: bytes) -> 'StationInfo':
|
||||
if len(detail) != 18:
|
||||
raise DecodeError('StationInfo length')
|
||||
return cls(detail[:8], detail[8:16], detail[16] != 0, detail[17])
|
||||
|
||||
|
||||
@dataclass
|
||||
class PotiUpdate:
|
||||
timestamp_ms: int
|
||||
latitude: int
|
||||
longitude: int
|
||||
semi_major_cm: int = 0
|
||||
semi_minor_cm: int = 0
|
||||
orientation_deci_degree: int = 0
|
||||
altitude_cm: Optional[int] = None
|
||||
speed_cm_s: Optional[int] = None
|
||||
heading_deci_degree: Optional[int] = None
|
||||
pai: bool = False
|
||||
|
||||
def encode(self) -> bytes:
|
||||
flags = ((1 if self.altitude_cm is not None else 0) | (2 if self.speed_cm_s is not None else 0) |
|
||||
(4 if self.heading_deci_degree is not None else 0) | (8 if self.pai else 0))
|
||||
return struct.pack('<QiiHHHBiHH', self.timestamp_ms, self.latitude, self.longitude, self.semi_major_cm,
|
||||
self.semi_minor_cm, self.orientation_deci_degree, flags, self.altitude_cm or 0,
|
||||
self.speed_cm_s or 0, self.heading_deci_degree or 0)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, body: bytes) -> 'PotiUpdate':
|
||||
if len(body) != 31:
|
||||
raise DecodeError('PotiUpdate length')
|
||||
t, lat, lon, a, b, o, flags, alt, spd, hdg = struct.unpack('<QiiHHHBiHH', body)
|
||||
return cls(t, lat, lon, a, b, o, alt if flags & 1 else None, spd if flags & 2 else None,
|
||||
hdg if flags & 4 else None, bool(flags & 8))
|
||||
|
||||
|
||||
@dataclass
|
||||
class DestinationArea:
|
||||
shape: int = 0
|
||||
latitude: int = 0
|
||||
longitude: int = 0
|
||||
distance_a: int = 0
|
||||
distance_b: int = 0
|
||||
angle: int = 0
|
||||
|
||||
def encode(self) -> bytes:
|
||||
return struct.pack('<BiiHHH', self.shape, self.latitude, self.longitude, self.distance_a, self.distance_b, self.angle)
|
||||
|
||||
@classmethod
|
||||
def read(cls, r: _Reader) -> 'DestinationArea':
|
||||
return cls(r.u8(), r.i32(), r.i32(), r.u16(), r.u16(), r.u16())
|
||||
|
||||
|
||||
@dataclass
|
||||
class BtpDataRequest:
|
||||
fl_sdu: bytes
|
||||
btp_type: int = 1
|
||||
destination_port: int = 2018
|
||||
destination_port_info: int = 0
|
||||
gn_packet_transport_type: int = TransportType.SHB
|
||||
gn_communication_profile: int = 1
|
||||
gn_security_profile: int = 0
|
||||
gn_traffic_class: int = 0xFF
|
||||
gn_maximum_packet_lifetime: int = 0xFF
|
||||
gn_maximum_hop_limit: int = 0
|
||||
gn_repetition_interval_ms: int = 0
|
||||
gn_repetition_maximum_ms: int = 0
|
||||
its_aid: int = 638
|
||||
permissions: bytes = b''
|
||||
context: bytes = b''
|
||||
area: Optional[DestinationArea] = None
|
||||
|
||||
def encode(self) -> bytes:
|
||||
if len(self.permissions) > 31:
|
||||
raise ValueError('SSP longer than 31 octets')
|
||||
out = struct.pack('<BHHBBBBBBHHI', self.btp_type, self.destination_port, self.destination_port_info,
|
||||
self.gn_packet_transport_type, self.gn_communication_profile, self.gn_security_profile,
|
||||
self.gn_traffic_class, self.gn_maximum_packet_lifetime, self.gn_maximum_hop_limit,
|
||||
self.gn_repetition_interval_ms, self.gn_repetition_maximum_ms, self.its_aid)
|
||||
out += bytes([len(self.permissions)]) + self.permissions + bytes([len(self.context)]) + self.context
|
||||
if self.gn_packet_transport_type == TransportType.GBC:
|
||||
out += (self.area or DestinationArea()).encode()
|
||||
out += struct.pack('<H', len(self.fl_sdu)) + self.fl_sdu
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def decode(cls, body: bytes) -> 'BtpDataRequest':
|
||||
r = _Reader(body)
|
||||
f = struct.unpack('<BHHBBBBBBHHI', r.bytes(19))
|
||||
permissions = r.bytes(r.u8())
|
||||
context = r.bytes(r.u8())
|
||||
area = DestinationArea.read(r) if f[3] == TransportType.GBC else None
|
||||
sdu = r.bytes(r.u16())
|
||||
r.done()
|
||||
return cls(sdu, *f, permissions=permissions, context=context, area=area)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BtpDataIndication:
|
||||
received_fl_sdu: bytes
|
||||
btp_type: int = 1
|
||||
destination_port: int = 0
|
||||
destination_port_info: int = 0
|
||||
gn_packet_transport_type: int = 1
|
||||
gn_traffic_class: int = 0
|
||||
gn_remaining_packet_lifetime: int = 0xFF
|
||||
gn_remaining_hop_limit: int = 0xFF
|
||||
source_gn_address: bytes = b'\0' * 8
|
||||
source_timestamp: int = 0
|
||||
source_latitude: int = 0
|
||||
source_longitude: int = 0
|
||||
security_report: int = 0
|
||||
its_aid: int = 0
|
||||
permissions: bytes = b''
|
||||
certificate_id: Optional[bytes] = None
|
||||
area: Optional[DestinationArea] = None
|
||||
|
||||
@property
|
||||
def report_name(self) -> str:
|
||||
return VERIFICATION_REPORTS[self.security_report] if self.security_report < len(VERIFICATION_REPORTS) else str(self.security_report)
|
||||
|
||||
def encode(self) -> bytes:
|
||||
out = struct.pack('<BHHBBBB8sIiiBI', self.btp_type, self.destination_port, self.destination_port_info,
|
||||
self.gn_packet_transport_type, self.gn_traffic_class, self.gn_remaining_packet_lifetime,
|
||||
self.gn_remaining_hop_limit, self.source_gn_address, self.source_timestamp,
|
||||
self.source_latitude, self.source_longitude, self.security_report, self.its_aid)
|
||||
out += bytes([len(self.permissions)]) + self.permissions
|
||||
out += bytes([1 if self.certificate_id else 0]) + (self.certificate_id or b'\0' * 8)
|
||||
out += bytes([1 if self.area else 0]) + (self.area.encode() if self.area else b'')
|
||||
out += struct.pack('<H', len(self.received_fl_sdu)) + self.received_fl_sdu
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def decode(cls, body: bytes) -> 'BtpDataIndication':
|
||||
r = _Reader(body)
|
||||
f = struct.unpack('<BHHBBBB8sIiiBI', r.bytes(34))
|
||||
permissions = r.bytes(r.u8())
|
||||
present = r.u8()
|
||||
certificate = r.bytes(8)
|
||||
area = DestinationArea.read(r) if r.u8() else None
|
||||
sdu = r.bytes(r.u16())
|
||||
r.done()
|
||||
return cls(sdu, *f, permissions=permissions, certificate_id=certificate if present else None, area=area)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredentialsProvision:
|
||||
total_length: int
|
||||
offset: int
|
||||
segment: bytes
|
||||
|
||||
def encode(self) -> bytes:
|
||||
return struct.pack('<HHB', self.total_length, self.offset, len(self.segment)) + self.segment
|
||||
|
||||
@classmethod
|
||||
def decode(cls, body: bytes) -> 'CredentialsProvision':
|
||||
r = _Reader(body)
|
||||
total, offset = r.u16(), r.u16()
|
||||
segment = r.bytes(r.u8())
|
||||
r.done()
|
||||
return cls(total, offset, segment)
|
||||
|
||||
|
||||
def provision_segments(bundle: bytes, segment_size: int = 200) -> List[CredentialsProvision]:
|
||||
"""Split a VCR1 bundle into CREDENTIALS_PROVISION messages."""
|
||||
return [CredentialsProvision(len(bundle), at, bundle[at:at + segment_size]) for at in range(0, len(bundle), segment_size)]
|
||||
|
||||
|
||||
@dataclass
|
||||
class IdChangeEvent:
|
||||
subscription: int
|
||||
command: int
|
||||
id: bytes
|
||||
subscriber_data: bytes = b''
|
||||
|
||||
def encode(self) -> bytes:
|
||||
return struct.pack('<QB8sB', self.subscription, self.command, self.id, len(self.subscriber_data)) + self.subscriber_data
|
||||
|
||||
@classmethod
|
||||
def decode(cls, body: bytes) -> 'IdChangeEvent':
|
||||
r = _Reader(body)
|
||||
s, c, i = struct.unpack('<QB8s', r.bytes(17))
|
||||
data = r.bytes(r.u8())
|
||||
r.done()
|
||||
return cls(s, c, i, data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IdChangeEventResponse:
|
||||
subscription: int
|
||||
return_code: bool
|
||||
|
||||
def encode(self) -> bytes:
|
||||
return struct.pack('<QB', self.subscription, 1 if self.return_code else 0)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, body: bytes) -> 'IdChangeEventResponse':
|
||||
if len(body) != 9:
|
||||
raise DecodeError('IdChangeEventResponse length')
|
||||
s, c = struct.unpack('<QB', body)
|
||||
return cls(s, c != 0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MfSetRequest:
|
||||
fac_id: int = 0
|
||||
command_ref: int = 0
|
||||
params: List[tuple] = field(default_factory=list) # (f_param_no, value)
|
||||
|
||||
def encode(self) -> bytes:
|
||||
out = struct.pack('<IBB', self.fac_id, self.command_ref, len(self.params))
|
||||
for no, value in self.params:
|
||||
out += struct.pack('<BI', no, value)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def decode(cls, body: bytes) -> 'MfSetRequest':
|
||||
r = _Reader(body)
|
||||
fac, ref, count = struct.unpack('<IBB', r.bytes(6))
|
||||
params = [struct.unpack('<BI', r.bytes(5)) for _ in range(count)]
|
||||
r.done()
|
||||
return cls(fac, ref, params)
|
||||
|
||||
|
||||
STATUS_FORMAT = '<IB8s8sBB' + 'I' * 7 + 'I' * 3 + 'I' * 4 + 'I' * 4 + 'Q'
|
||||
STATUS_FIELDS = ['uptime_ms', 'configured', 'gn_address', 'identifier', 'change_pending', 'tickets',
|
||||
'signed_messages', 'refused_no_ticket', 'refused_change_pending', 'refused_permission',
|
||||
'sign_failed', 'verified', 'rejected', 'requests_accepted', 'requests_refused', 'indications',
|
||||
'radio_submitted', 'radio_failed', 'radio_received', 'radio_dropped', 'link_rx_frames',
|
||||
'link_crc_errors', 'link_malformed', 'poti_updates', 'its_time_ms']
|
||||
|
||||
|
||||
@dataclass
|
||||
class Status:
|
||||
uptime_ms: int = 0
|
||||
configured: int = 0
|
||||
gn_address: bytes = b'\0' * 8
|
||||
identifier: bytes = b'\0' * 8
|
||||
change_pending: int = 0
|
||||
tickets: int = 0
|
||||
signed_messages: int = 0
|
||||
refused_no_ticket: int = 0
|
||||
refused_change_pending: int = 0
|
||||
refused_permission: int = 0
|
||||
sign_failed: int = 0
|
||||
verified: int = 0
|
||||
rejected: int = 0
|
||||
requests_accepted: int = 0
|
||||
requests_refused: int = 0
|
||||
indications: int = 0
|
||||
radio_submitted: int = 0
|
||||
radio_failed: int = 0
|
||||
radio_received: int = 0
|
||||
radio_dropped: int = 0
|
||||
link_rx_frames: int = 0
|
||||
link_crc_errors: int = 0
|
||||
link_malformed: int = 0
|
||||
poti_updates: int = 0
|
||||
its_time_ms: int = 0
|
||||
|
||||
def encode(self) -> bytes:
|
||||
return struct.pack(STATUS_FORMAT, *[getattr(self, name) for name in STATUS_FIELDS])
|
||||
|
||||
@classmethod
|
||||
def decode(cls, body: bytes) -> 'Status':
|
||||
if len(body) != struct.calcsize(STATUS_FORMAT):
|
||||
raise DecodeError('Status length %d' % len(body))
|
||||
return cls(**dict(zip(STATUS_FIELDS, struct.unpack(STATUS_FORMAT, body))))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
code: int
|
||||
detail: bytes = b''
|
||||
|
||||
def encode(self) -> bytes:
|
||||
return bytes([int(self.code), len(self.detail)]) + self.detail
|
||||
|
||||
@classmethod
|
||||
def decode(cls, body: bytes) -> 'Result':
|
||||
r = _Reader(body)
|
||||
code = r.u8()
|
||||
detail = r.bytes(r.u8())
|
||||
r.done()
|
||||
return cls(code, detail)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
try:
|
||||
return Code(self.code).name
|
||||
except ValueError:
|
||||
return 'code_0x%02x' % self.code
|
||||
Reference in New Issue
Block a user