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:

  • pairingPairingRequest, PairingRequestAck, PairingEncoding, PairingOption, PairingConfiguration, PairingConfigurationAck, PairingSecret, PairingSecretAck, and the PairingMessage envelope (protocol_version=1, status=2, bodies at 10/11/20/30/31/40/41).

  • remoteRemoteDeviceInfo, RemoteConfigure, RemoteSetActive, RemotePingRequest/Response, RemoteKeyInject, and the RemoteMessage envelope (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_pairingfinish_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 on self.

  • 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 RemoteConfigureRemoteSetActive 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_textUnsupported (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).