Practical reference for the phone side of AAP, as implemented here. Written from the aasdk protos, the Head Unit Integration Guide (HUIG), observed traffic against a real head unit, and mistakes we have actually made.
This is a working document: where the spec is silent or ambiguous, that is called out explicitly, because those gaps are where bugs hide.
- Wire/framing, TLS, channel model → §1–§3
- Message catalogue per channel → §4
- Sensor/pub-sub model and its correlation hazard → §5
- Video, focus and flow control → §6
- Audio, input, Bluetooth, navigation → §7–§10
- Known ambiguities and traps → §11 ← read this before trusting any log
Authoritative sources:
- Protos:
third_party/aasdk/aasdk_proto/*.proto(aasdk, GPL-3.0) - HUIG v1.3: https://milek7.pl/.stuff/galdocs/huig13_cache.html
- Ecosystem survey:
research.md
Every message is a frame on one of the multiplexed channels:
[channelId:1][flags:1][length:2 big-endian][payload:length]
flags bits (MessageFramer.FrameHeader):
| Bit | Meaning |
|---|---|
0x03 |
frame type: 0x00=FIRST, 0x01=LAST, 0x02=MIDDLE, 0x03=SINGLE |
0x04 |
control message |
0x08 |
payload is encrypted (TLS record) |
Fragmented messages use FIRST/MIDDLE/LAST. The FIRST frame carries the total length:
FIRST: [channel:1][flags:1][chunkLen:2][totalPlaintextLen:4][encrypted:chunkLen]
MID/LAST:[channel:1][flags:1][chunkLen:2][encrypted:chunkLen]
Each chunk is a separate TLS record — small records keep the HU's reassembly
buffer happy. We fragment at 2 KB (FRAGMENT_SIZE).
Trap: our
fragmentEnableddefault is off. Fragmentation was once blamed for the video disconnect, so the path is less exercised than it looks.
- TLS 1.2, phone is the server (the HU initiates).
- Handshake is in-band: TLS records travel as payload of control-channel
(
ch=0) messages of typeSSL_HANDSHAKE (0x0003), not as raw bytes on the wire. - After the handshake, set the
0x08encrypted flag on frames. AUTH_COMPLETE (0x0004)flows HU → phone only. Sending it back triggersMESSAGE_UNEXPECTED_MESSAGE (0x00FF)and eventually a disconnect. Seeconnection-stability.md§1.2.
Thread safety: SSLEngine is not safe for concurrent wrap()/unwrap().
We hold a single lock across encrypt/decrypt.
Known unexplained failure: a 72-minute run ended with
SSLV3_ALERT_BAD_RECORD_MAC(record type0x15= alert) after ~4300 pings / ~35k sensor batches. Cause unknown.connection-stability.md§2.6.
Channels are numbered by the HU in SERVICE_DISCOVERY_RESPONSE. Do not
hardcode channel numbers — parse the response and map by capability:
| Channel | Capability | Typical id (this HU) |
|---|---|---|
| Control | — | 0 (fixed) |
| Video | av=true, stream_type=VIDEO(3) |
1 |
| Input | input=true |
2 |
| Audio out | av=true, stream_type=AUDIO(1) |
3 |
| Audio in | av_input=true |
4 |
| Sensor | sensor=true |
6 |
| Bluetooth | bt=true |
7 |
| Navigation | nav=true |
8 |
Trap:
ServiceDiscoveryBuilderhardcodes the sensorchannel_idto 4 while runtime assigns 6. Harmless so far but wrong.
Control-channel message ids (ControlMessageIdsEnum.proto) are implemented
exactly; see ProtocolEngine.ControlMessageType.
| Id | Message | Direction |
|---|---|---|
0x0001/0x0002 |
VERSION_REQUEST / RESPONSE | both |
0x0003 |
SSL_HANDSHAKE | both (in-band TLS) |
0x0004 |
AUTH_COMPLETE | HU → phone only |
0x0005/0x0006 |
SERVICE_DISCOVERY_REQUEST / RESPONSE | both |
0x0007/0x0008 |
CHANNEL_OPEN_REQUEST / RESPONSE | both |
0x000B/0x000C |
PING_REQUEST / PING_RESPONSE | both, ~1 Hz |
0x000D/0x000E |
NAVIGATION_FOCUS_REQUEST / RESPONSE | both |
0x000F/0x0010 |
SHUTDOWN_REQUEST / RESPONSE | both |
0x0011 |
VOICE_SESSION_REQUEST | HU → phone |
0x0012/0x0013 |
AUDIO_FOCUS_REQUEST / RESPONSE | phone→HU request; HU→phone notification |
Ping note: our request carries a timestamp in field 1 (System.currentTimeMillis() * 1000, microseconds). The HU echoes it; we log the RTT. Whether the HU
cares about the value is unverified — it has never rejected one.
Audio focus: the phone sends RELEASE(4) on connect (HUIG: so the HU keeps
default focus). AUDIO_FOCUS_REQUEST from the HU is a notification, not a
request — do not reply, or you reproduce the AUTH_COMPLETE bug class.
Pub-sub. Phone sends SENSOR_START_REQUEST (0x8001) per type; HU replies
SENSOR_START_RESPONSE (0x8002); data arrives as SENSOR_EVENT_INDICATION
(0x8003) batches. There is also SENSOR_ERROR (0x8004).
message SensorStartRequestMessage { SensorType sensor_type = 1; int64 refresh_interval = 2; }
message SensorStartResponseMessage { Status status = 1; } // ← NO sensor_typeSensorEventIndication fields are repeated and numbered by SensorType value,
so field N == SensorType N. That is how batches are decoded.
A response cannot be matched to its request by anything other than arrival order, because the response carries no sensor type. Consequences:
- If one response is lost or reordered, every subsequent verdict is attributed to the wrong type — silently, and the resulting table looks perfectly plausible.
- This actually happened (2026-10-23): 3 responses went unattributed and every verdict shifted by three, producing a wrong capability table that then drove a wrong code change.
Mitigation (implemented): count matches vs. requests. Every response is
logged numbered; an unmatched one logs UNMATCHED — correlation may have shifted!; and lifecycleSummary() ends with:
correlation=OK|SUSPECT (matched=N unmatched=N pending=N)
Rule: treat any
SUSPECTrun's per-type verdicts as invalid. The surrounding data (timings, gaps, other channels) is still fine.
We observed status=247 for rejected subscriptions. 247 is not in
StatusEnum at all. External research (2026-10-26) found the real enum uses
signed negative values:
OK = 0
INVALID_SENSOR = -9
COMMAND_NOT_SUPPORTED = -250
FRAMING_ERROR = -251
BUS_UNEXPECTED = -253
BUSY = -254
OUT_OF_MEMORY = -255
-9 as a 32-bit value is 0xFFFFFFF7; truncated to one byte that is 0xF7 =
247. So 247 is most likely INVALID_SENSOR that we are mangling by reading
only the low byte — not a proprietary HU code. Unverified, but it fits
exactly and it is our parser, not the HU, that is the likely culprit.
This is the same class of defect as the tag bug in §5.3c: we read a value as one
byte when the wire format allows more. SensorStartResponseMessage.status is an
enum (int32) and must be read as a full varint with sign extension.
Our current reader: payload[1].toInt() and 0xFF — takes one byte and masks.
To read a protobuf int32 properly we must read the full varint and
sign-extend it. TODO.
Every field is repeated data.X — i.e. wiretype 2 (length-delimited nested
message), tag = (SensorType << 3) | 2:
80 03 | 1a 03 | 08 80 25
msgid tag │ └ nested Speed body: field 1 (speed), varint 0x1280 = 4736
f3 wt2└ len 3
Two consequences we got wrong or nearly got wrong:
- The body must be recursed into, not read as raw outer varints.
- One message may carry several sensor types ("one or more sensor types per message"), and each field is repeated to allow batching.
Confirmed against real captures: 80 03 1a 03 08 80 25 → speed 4736/1000 =
4.736 m/s, and LOCATION's nested body carries 7 fields including a
sign-extended negative longitude (18 94 c2 b2 ef ff ff ff ff ff 01).
This was our bug, and it is fixed. Full decrypted payloads (now logged, see §6.1) showed the truth:
80 03 | 9a 01 | 09 | 08 ee 05 10 f2 02 18 f0 4c
msgid tag len ACCEL body: x=750, y=370, z=9840
The tag for field 19 is (19 << 3) | 2 = 154 = 0x9A. 0x9A has bit 7 set,
so the tag is a TWO-byte varint: 9a 01. We were reading the tag as a single
byte, so 0x01 looked like a length of 1 — we then took one payload byte and
discarded the rest. Hence "declares 20-38 bytes, delivers 1".
Why only ACCEL/GYRO: any field ≥ 16 has a tag ≥ 0x80 and therefore a multi-byte
varint tag. ACCEL(19) and GYRO(20) are the only such types this HU sends. Fields
1-15 (LOCATION, CAR_SPEED, DRIVING_STATUS, NIGHT_DATA, PARKING_BRAKE)
were always fine, which is exactly the pattern observed.
Lesson: protobuf tags are varints, not bytes. The same one-byte assumption
was also in the length reader and in the wiretype-0 skip; all now use a shared
readVarint.
Also fixed: a declared length beyond the buffer used to break silently; it now
logs SENSOR FRAMING: … so a framing fault is visible instead of masquerading as
"the sensor sent nothing".
It is tempting to read our "12 rejected types" list as a deficiency, in the phone side or the head unit. It is neither. The spec makes almost everything optional, and explicitly forbids a HU from faking data it does not have.
From HUIG v1.3 "Integrating Vehicle Data" (verified directly against https://milek7.pl/.stuff/galdocs/huig13_cache.html):
"Vehicle data MUST be shared as specified below, and MUST never be faked, simulated, or falsified."
and for gear specifically:
"If present, it MUST accurately represent the actual transmission/gear status and MUST NOT be synthesized. If the actual transmission/gear status is not available, this status SHOULD NOT be sent."
Only these are genuinely REQUIRED:
| Type | Required |
|---|---|
Drive Level (our DRIVING_STATUS) |
REQUIRED |
Day/Night Mode (our NIGHT_DATA) |
REQUIRED |
| GPS location | required if the HU has nav sensors |
| Car speed | required if present |
| Accelerometer / Gyroscope | required if present |
Everything else is OPTIONAL: RPM, gear, odometer, parking brake, fuel level / range, compass, doors, headlights, tire pressure, passenger presence, outside temperature / pressure / HVAC, OBD diagnostics.
So refusing RPM/gear/fuel is the conformant behaviour when the HU does not
have those signals. The spec offers SensorError for reporting this, but says
errors "can" be reported — it does not mandate it, so a silent rejection is
permitted.
Why this particular subset. Physical CAN topology: the infotainment unit sits on the comfort/media bus and can see wheel speed (ABS), parking brake (a simple bit), and its own GPS/IMU. RPM, gearbox, HVAC and OBD-II live on the powertrain bus, reachable only through a CAN gateway the manufacturer must deliberately route — cost with no consumer benefit. (Inferred from CAN topology plus the spec; no OEM policy document was found.)
Official Android Auto sees the same subset. Same channel, same rules. AA's projected API exposes speed, odometer, fuel, location, accel/gyro and compass (permission-gated), and does not expose RPM or gear at all. Google's docs warn: "Don't assume that the car's data is available at all times." The two signals AA genuinely needs are speed (keyboard lockout while moving) and day/night + drive level — all of which this HU provides.
Consequence for us: the 7 working types are the realistic ceiling for this unit. Do not treat the rejected list as a bug to fix.
Real payloads are checked in as test vectors:
app/src/test/java/org/openandroidauto/sensor/CapturedSensorVectors.kt
(session 2026-10-26_08-54-38). Tests exercising the whole chain:
SensorFramingTest— message id → outer tag → nested body → scaled value, including multi-byte varints, the sign-extended negative longitude, the bitmask driving status, and the malformed 1-byte ACCEL/GYRO case.MessageFramerCapturedTest— framing: several frames per read, partial trailing frame, big-endian lengths, and "declared length == bytes carried".
When adding a new sensor bug, add the offending bytes as a vector rather than describing them in prose.
CHANNEL_OPEN → SETUP (0x8000) → SETUP_RESPONSE/CONFIG (0x8003)
→ VIDEO_FOCUS_NOTIFICATION (0x8008) → START (0x8001) → media frames
Message ids (AVChannelMessageIdsEnum.proto):
| Id | Message |
|---|---|
0x0000 |
AV_MEDIA_WITH_TIMESTAMP — [type:2][timestampUs:8][H.264] |
0x0001 |
AV_MEDIA_INDICATION — used for codec config |
0x8000 |
SETUP_REQUEST |
0x8001 |
START_INDICATION |
0x8002 |
STOP_INDICATION |
0x8003 |
SETUP_RESPONSE (aka CONFIG) |
0x8004 |
AV_MEDIA_ACK_INDICATION |
0x8007 |
VIDEO_FOCUS_REQUEST |
0x8008 |
VIDEO_FOCUS_INDICATION |
The proto says:
message AVChannelSetupResponse { required Status media_status=1; required uint32 max_unacked=2;
repeated uint32 configs=3; } // a LIST
message AVChannelSetupRequest { uint32 config_index = 1; } // WE choose thisSo the phone picks the config index and sends it in SETUP; the HU returns a
prioritised list. Note this contradicts the HUIG prose, which describes the MD
sending the index in Start.
Our current behaviour is wrong on both counts:
sendSetup()sends08 03(claiming config index 3), not the index of the config we advertise.sendStart()puts a config index in field 2 where the proto expects a session id.
The HU tolerates it (video displays), but it is not compliant. See
connection-stability.md §1.7, TODO item 2.
VideoFocusMode: NONE=0, FOCUSED=1, UNFOCUSED=2.
The HU owns the display. When it sends UNFOCUSED (e.g. reversing camera) the
phone must stop sending frames or it corrupts whatever the HU is showing.
Our symptom was visible digital corruption of the reversing-camera image.
Implemented: frames are gated on a videoFocusHeld flag. On regaining focus we
also send VIDEO_FOCUS_REQUEST (0x8007, focus_mode=FOCUSED) and request an
encoder IDR so the HU decoder resyncs at once
(VideoFocusRequest{ disp_index=1, focus_mode=2 required, focus_reason=3 required }).
0x8004 AVMediaAckIndication { session=1; value=2; }. value is a
credit/burst count, not a per-frame ACK. The HU granted max_unacked=100.
We pause when unacked would exceed it. In practice this never triggers — 0
backlog at every disconnect we have measured, so flow control is not the
disconnect cause.
H.264 Baseline Profile, level 3.1, I+P frames only (no B-frames). HUIG:
800×480 @ 30 fps is the guaranteed baseline. Frames should be sent only for UI
updates ("does not send frames when no updates are available"), plus enough
idle frames to satisfy the HU's decoder depth. See connection-stability.md
§1.9 — we now do this.
Bitrate mode matters for real capture. For live screen capture the encoder
must be set to KEY_BITRATE_MODE = BITRATE_MODE_CBR; left unset, MediaCodec is
quality-driven and emits frames far over the requested bitrate (up to ~176 KB
per frame observed) which makes the HU drop the link within seconds. Non-keyframe
output is also capped on the wire (~12 fps; keyframes pass) — see
connection-stability.md §1.13.
SETUP_REQUEST (0x8000) → CONFIG → stream PCM/AAC. Silence must be streamed
continuously if the channel is open, else the HU may time it out. Requires
AUDIO_FOCUS(GAIN) and up to 500 ms wait for the grant before sending audio,
and must stop on focus loss. Audio is n-ACK flow controlled.
Observation: enabling audio silence accelerates the disconnect (sessions dropped from 9–93 s to 7–9 s). Correct per HUIG but currently disabled by default for stability testing.
AV_INPUT_OPEN_REQUEST (0x8005) / RESPONSE (0x8006);
INPUT_EVENT_INDICATION (0x8001) carries touches/keys;
BINDING_REQUEST (0x8002) / RESPONSE (0x8003).
Coordinates arrive in head-unit space and must be scaled to phone resolution.
Ids from BluetoothChannelMessageIdsEnum.proto: PAIRING_REQUEST=0x8001,
PAIRING_RESPONSE=0x8002, AUTH_DATA=0x8003. The phone must send a real BT MAC;
BluetoothAdapter.getAddress() returns 02:00:00:00:00:00 on Android 6+, so we
read Settings.Secure "bluetooth_address" instead.
The HU reports
already_paired=truein testing, so the pairing exchange is exercised only shallowly.
Phone → HU, for instrument-cluster turn-by-turn. The HU does not normally send nav messages to the phone.
Unverified ids. The aasdk protos in this repo contain no navigation channel message-id enum — only
NAVIGATION_FOCUS_REQUEST/RESPONSEon the control channel. OurNavigationChanneluses ids0x8003(status),0x8004(turn event),0x8005(distance event), which appear to have been chosen by hand and are not confirmed against the protos or observed traffic. Treat as unverified; the feature is untested on hardware.Message shapes do exist in the protos:
NavigationChannelData.proto,NavigationImageOptionsData.proto,NavigationFocusRequestMessage.proto,NavigationFocusResponseMessage.proto.
- Protobuf tags are VARINTS, not bytes (§5.3c). Any field ≥ 16 has a tag
≥ 0x80 and is therefore ≥ 2 bytes on the wire. Reading a tag as a single byte
made ACCEL/GYRO look completely broken for weeks. Same applies to lengths and
to
status(§5.3). - Sensor responses are uncorrelatable except by order (§5.2). A dropped
response silently shifts all later verdicts. Always check
correlation=OK|SUSPECT. AUTH_COMPLETEmust never be sent to the HU (§2).- HU→phone
AUDIO_FOCUS_REQUESTis a notification — do not reply (§4). - Failure status is
247, not the proto'sFAIL=1(§5.3). - Types 18 and 21 have no payload field — they can never deliver (§5.4).
DEAD_RECONING(14) is actuallySteeringWheel(§5.6).- Video config index is ours to choose and send in SETUP (§6.2) — the HUIG prose contradicts the proto; trust the proto.
- Video focus must be released or the HU's own UI corrupts (§6.3).
- Channel ids must be parsed, not hardcoded (§3).
- Fragmentation is off by default, so that path is under-tested (§1).
- A HU rejecting sensor types is correct, not a defect (§5.6b). Most types are OPTIONAL and the spec forbids faking absent data.
- Protos:
third_party/aasdk/aasdk_proto/(read themessagebodies, not the GPL headers — the headers dominate the files). - HUIG: the milek7 mirror, fetched and text-searched. Written head-unit-side; it describes what to send, not how to interpret what comes back.
- Observed traffic:
aa_log_*.txtfrom the real HU, with the logging described inconnection-stability.md§6.
Corrections welcome. Several statements here have already been wrong once
(see connection-stability.md §5 for the sensor capability saga). Where a claim
rests on a single observation it is marked as such.