ADR-0007: Android TV Remote v2 protocol
Status |
Accepted |
|---|---|
Area |
Device backends |
Context
This is the Google/Android-TV protocol (androidtvremote2, covering Sony/Shield/Chromecast/Philips/TCL), NOT Fire TV (ADB) or Samsung (WS). It is the same protocol Home Assistant’s "Android TV Remote" integration speaks.
The transport is two raw-TLS ports, each carrying length-delimited protobuf — a protobuf varint byte-length prefix followed by the message, exactly Java writeDelimitedTo (confirmed against the androidtvremote2 reference base.py):
-
6467 = pairing
-
6466 = remote control
The backend is AndroidTvBackend in crates/tv-core/src/androidtv.rs.
Decision
Protobuf
Protobuf is done by hand, no protoc/prost-build. Messages are [derive(prost::Message)] structs with explicit [prost(…tag=..)] field numbers mirroring the reference pairingmessage.proto / remotemessage.proto (fetched from the louis49 + tronikos repos and pinned in comments), avoiding the toolchain dependency plus Android cross-compile pain.
Enums are modelled as plain i32 — a protobuf enum is a varint identical to int32 on the wire — so we set constants (STATUS_OK=200, ROLE_TYPE_INPUT=1, ENCODING_HEXADECIMAL=3, DIRECTION_SHORT=3) directly and skip a pile of prost enum types.
Modelled messages:
-
pairing —
PairingRequest,PairingRequestAck,PairingEncoding,PairingOption,PairingConfiguration,PairingConfigurationAck,PairingSecret,PairingSecretAck, and thePairingMessageenvelope (protocol_version=1,status=2, bodies at 10/11/20/30/31/40/41). -
remote —
RemoteDeviceInfo,RemoteConfigure,RemoteSetActive,RemotePingRequest/Response,RemoteKeyInject, and theRemoteMessageenvelope (bodies at 1/2/8/9/10).
The remote envelope is deliberately partial — prost silently skips the many other fields a real TV also sends (IME, voice, volume, error).
TLS & certs
Mutual TLS with a persisted self-signed RSA-2048 client cert. The whole security model is cert-based: pairing makes the TV remember our client cert, and the remote channel then authenticates us purely by presenting it (no token).
We mint the cert once and persist androidtv_cert.pem + androidtv_key.pem under <app data>/androidtv/ (shared across all Android TVs, like the Fire TV ADB key).
RSA (not ECDSA) is mandatory because the pairing secret hashes the RSA modulus/exponent of both certs. The RSA key is generated with the pure-Rust rsa crate (Android-safe) and handed to rcgen with the ring backend (default-features=false, features=["crypto","ring","pem"]) to self-sign the X.509 wrapper — never aws-lc-rs (no C toolchain, per the TLS decisions).
tls::client_auth_config reuses the existing AcceptAnyServerCert verifier (the TV’s own cert is self-signed / no stable hostname — same LAN-appliance threat model as Samsung/LG) and adds .with_client_auth_cert.
Pairing
Two-phase pairing (start_pairing → finish_pairing(code)), because the TV shows a 6-hex code the user must type mid-flow — the object-safe TvBackend::pair() can’t express that (it returns an error pointing at the two methods).
-
Phase 1 does request→option→configuration (each a
PairingMessage, status checked == 200) and leaves the 6467 stream + the captured server leaf cert onself. -
Phase 2 computes the secret and sends it.
The pairing secret is byte-for-byte the androidtvremote2 (HA) reference:
SHA256(client_modulus ‖ client_exponent ‖ server_modulus ‖ server_exponent ‖ nonce)
nonce = code_bytes[1..]
assert hash[0] == code_bytes[0] // a wrong code → PairingRejected
send PairingSecret{secret: hash}
Modulus/exponent are minimal big-endian bytes (leading zeros stripped) — matching the reference’s bytes.fromhex(f"{n:X}") (256-byte modulus, no DER sign-zero) and bytes.fromhex(f"0{e:X}") (65537 → 01 00 01). The server cert comes off the live TLS session (peer_certificates()); both certs are parsed with x509-parser (pure Rust) to pull out (modulus, exponent).
Remote channel
Remote channel keepalive is a background read loop. After the TV-driven RemoteConfigure→RemoteSetActive handshake (we reply code1/active=622), the TV periodically sends RemotePingRequest{val1,val2} and drops the socket if we don’t answer RemotePingResponse{val1}.
We tokio::io::split the TLS stream, put the write half behind an Arc<Mutex>, and spawn a loop that answers pings (and drains everything else); send_key just locks the writer and injects a RemoteKeyInject{key_code, direction:SHORT}. An idle timeout ends the loop (link dead → is_connected() flips via JoinHandle::is_finished).
Keymap
Keymap (Android RemoteKeyCode, exhaustive, no _ arm — same values as the Fire TV map):
-
Power 26, Home 3, Back 4, Menu 82
-
Up/Down/Left/Right 19/20/21/22
-
Ok 23 (
DPAD_CENTER) -
Vol± 24/25, Mute 164, Ch± 166/167
-
Play/Pause 85, Stop 86, Rewind 89, FastForward 90
-
Source 178 (
TV_INPUT), Info 165, Exit 4 (reuses BACK) -
Digit(0..9) 7..16
send_text → Unsupported (IME field-state tracking not wired). power(on): POWER when connected; power-on from cold is impossible over this channel (socket unreachable) → Unsupported.
Discovery
Discovery: mDNS browse androidtvremote2._tcp.local. (via the existing mdns-sd), name from TXT fn (else the instance label), MAC from TXT bt. Folded into discover() concurrently; deduped by IP, and a real androidtv entry _replaces a same-IP DIAL "unsupported" card (a Chromecast-with-Google-TV that answered both).
Testing
The mock reuses tv-core’s proto types (pub mod proto + pub framing helpers read_delimited/write_delimited), so mock and backend are wire-identical by construction. This adds tv-core as a normal dep of tv-mock while tv-core dev-depends on tv-mock — cargo permits the cycle because it only closes through dev-dependencies (the Android/lib build of tv-core never pulls tv-mock). The mock uses with_no_client_auth, so the backend’s client cert isn’t exchanged there (fine — the test targets the protobuf flow, not the cert trust a real TV establishes at pairing).
Consequences
UNVERIFIED without a real Android TV (flagged loudly in-source): the entire live pairing handshake on 6467 — the message exchange with a genuine TV, the displayed code, and the secret being accepted (PairingSecretAck). A mock can’t reproduce the cert-secret crypto faithfully, so it’s unit-tested structurally instead (pairing_secret_is_structural_and_checks_code_byte, with a fixed input vector + the hash[0]==code[0] gate).
The remote channel (6466) IS exercised end-to-end: tv-mock’s `MockAndroidTv does the configure/set-active handshake, fires a ping, and records injected keycodes; tests/androidtv_proto.rs connects → injects 5 keys → asserts the exact codes → disconnects.
Also unstated by the reference and therefore a possible mismatch on real hardware: the PairingRequest.service_name (we use the reference’s "atvremote") and the exact RemoteDeviceInfo string fields (informational, not protocol-critical).