Skip to content

Google TV (aka Android TV) Remote Control

Aymeric edited this page Jun 16, 2021 · 19 revisions

This page explains how we can pair with a remote Android/Google TV to then send commands (like changing the channel, the volume, etc). Thanks to @hubertlejaune for his tremendous help.

The Android TV (aka server in this document) should have 2 open ports: 6466 and 6467.

To know more about the Android TV, we can enter the below Linux command:

openssl s_client -connect SERVER_IP:6467 -prexit -state -debug

Which will return some information, including the server's public certificate.

If you only want the server's public certificate:

openssl s_client -showcerts -connect SERVER_IP:6467 </dev/null 2>/dev/null|openssl x509 -outform PEM > server.pem

Pairing

The pairing protocol will happen on port 6467.

Client's certificate

It's required to generate our own (client) certificate.

In PHP we can do it with the below code:

<?php
// the commande line is: php generate_key.php > client.pem

// certificate details (Distinguished Name)
// (OpenSSL applies defaults to missing fields)
$dn = array(
  "commonName" => "atvremote",
  "countryName" => "US",
  "stateOrProvinceName" => "California",
  "localityName" => "Montain View",
  "organizationName" => "Google Inc.",
  "organizationalUnitName" => "Android",
  "emailAddress" => "[email protected]"
);

// create certificate which is valid for ~10 years
$privkey = openssl_pkey_new();
$cert = openssl_csr_new($dn, $privkey);
$cert = openssl_csr_sign($cert, null, $privkey, 3650);

// export public key
openssl_x509_export($cert, $out);
echo $out;

// export private key
$passphrase = null;
openssl_pkey_export($privkey, $out, $passphrase);
echo $out;

It will generate a file called client.pem that contains both the public and the private keys for our client.

Connection to the server

You need to open a TLS/SSL connection to the server using port 6467.

In PHP, you could use https://github.com/reactphp/socket:

<?php
use React\EventLoop\Factory;
use React\Socket\Connector;
use React\Socket\SecureConnector;
use React\Socket\ConnectionInterface;

require __DIR__ . '/./vendor/autoload.php';

$host = 'SERVER_IP';
$loop = Factory::create();
$tcpConnector = new React\Socket\TcpConnector($loop);
$dnsResolverFactory = new React\Dns\Resolver\Factory();
$dns = $dnsResolverFactory->createCached('8.8.8.8', $loop);
$dnsConnector = new React\Socket\DnsConnector($tcpConnector, $dns);

$connector = new SecureConnector($dnsConnector, $loop, array(
  'allow_self_signed' => true,
  'verify_peer' => false,
  'verify_peer_name' => false,
  'dns' => false,
  'local_cert' => 'client.pem'
));

$connector->connect('tls://' . $host . ':6467')->then(function (ConnectionInterface $connection) use ($host) {
  $connection->on('data', function ($data) use ($connection) {
    $dataLen = strlen($data);
    echo "data recv => ".$data." (".strlen($data).")\n";
    // deal with the messages received from the server
  });
  
  // below we can send the first message
  $connection->write(/* first message here */);
}, 'printf');

$loop->run();
?>

Protocol

⚠️ Attention, each message is sent as a JSON string, but with two components/parts:

  • (first) we send the length of the message (JSON string) on 4 bytes,
  • (second) we send the message (JSON string) itself.

PAIRING_REQUEST(10)

As soon as we are connected to the server, we send a PAIRING_REQUEST(10) message (type = 10).

The first message to send is:

{"protocol_version":1,"payload":{"service_name":"androidtvremote","client_name":"CLIENT_NAME"},"type":10,"status":200}

The server returns a PAIRING_REQUEST_ACK(11) message with type is 11 and status is 200:

{"protocol_version":1,"payload":{},"type":11,"status":200}

OPTIONS(20)

Then the client replies with a OPTIONS(20) message (type = 20):

{"protocol_version":1,"payload":{"output_encodings":[{"symbol_length":4,"type":3}],"input_encodings":[{"symbol_length":4,"type":3}],"preferred_role":1},"type":20,"status":200}

The server returns a OPTIONS(20) message with type is 20 and status is 200.

CONFIGURATION(30)

Then the client replies with a CONFIGURATION(30) message (type = 30):

{"protocol_version":1,"payload":{"encoding":{"symbol_length":4,"type":3},"client_role":1},"type":30,"status":200}

The server returns a CONFIGURATION_ACK(31) message with type is 31 and status is 200.

🎉 The code appears on the TV screen!

SECRET(40)

Then the client replies with a SECRET(40) message (type = 40):

{"protocol_version":1,"payload":{"secret":"encodedSecret"},"type":40,"status":200}

At this stage, the TV screen shows a code with 4 characters (e.g. 4D35).

To find the encodedSecret:

  • we use a SHA-256 hash;
  • we add the client public key's modulus to the hash;
  • we add the client public key's exponent to the hash;
  • we add the server public key's modulus to the hash;
  • we add the server public key's exponent to the hash;
  • we add the last 2 characters of the code to the hash (in the example it's 35).

The result of the hash is then encoded in base64.

The server returns a SECRET_ACK(41) message with type is 41 and status is 200, as well as an encoded secret that permits to verify – we didn't try to decode it, but it's probably the first 2 characters of the code:

{"protocol_version":1,"payload":{"secret":"encodedSecretAck"},"type":41,"status":200}

PHP Code

(you can find some Java code that produces pretty much the same)

Here is the related PHP code:

<?php
use React\EventLoop\Factory;
use React\Socket\Connector;
use React\Socket\SecureConnector;
use React\Socket\ConnectionInterface;

require __DIR__ . '/./vendor/autoload.php';

$host = 'SERVER_IP';
$loop = Factory::create();
$tcpConnector = new React\Socket\TcpConnector($loop);
$dnsResolverFactory = new React\Dns\Resolver\Factory();
$dns = $dnsResolverFactory->createCached('8.8.8.8', $loop);
$dnsConnector = new React\Socket\DnsConnector($tcpConnector, $dns);

// get the server's public certificate
exec("openssl s_client -showcerts -connect ".escapeshellcmd($host).":6467 </dev/null 2>/dev/null|openssl x509 -outform PEM > server.pem");

$connector = new SecureConnector($dnsConnector, $loop, array(
  'allow_self_signed' => true,
  'verify_peer' => false,
  'verify_peer_name' => false,
  'dns' => false,
  'local_cert' => 'client.pem'
));

// return the message's length on 4 bytes
function getLen($len) {
  return chr($len>>24 & 0xFF).chr($len>>16 & 0xFF).chr($len>>8 & 0xFF).chr($len & 0xFF);
}

// connect to the server
$connector->connect('tls://' . $host . ':6467')->then(function (ConnectionInterface $connection) use ($host) {
  $connection->on('data', function ($data) use ($connection) {
    $dataLen = strlen($data);
    echo "data recv => ".$data." (".strlen($data).")\n";

    // the first response from the server is the message's size on 4 bytes (that looks like a char to convert to decimal) – we can ignore it
    // only look at messages longer than 4 bytes
    if ($dataLen > 4) {
      // decode the JSON string
      $res = json_decode($data);
      // check the status is 200
      if ($res->status === 200) {
        // check at which step we are
        switch($res->type) {
          case 11:{
            // message to send:
            // {"protocol_version":1,"payload":{"output_encodings":[{"symbol_length":4,"type":3}],"input_encodings":[{"symbol_length":4,"type":3}],"preferred_role":1},"type":20,"status":200}
            $json = new stdClass();
            $json->protocol_version = 1;
            $json->payload = new stdClass();
            $json->payload->output_encodings = [];
            $encoding = new stdClass();
            $encoding->symbol_length = 4;
            $encoding->type = 3;
            array_push($json->payload->output_encodings, $encoding);
            $json->payload->input_encodings = [];
            $encoding = new stdClass();
            $encoding->symbol_length = 4;
            $encoding->type = 3;
            array_push($json->payload->input_encodings, $encoding);
            $json->payload->preferred_role = 1;
            $json->type = 20;
            $json->status = 200;
            $payload = json_encode($json);
            $payloadLen = strlen($payload);
            $connection->write(getLen($payloadLen));
            $connection->write($payload);
            break;
          }
          case 20:{
            // message to send:
            // {"protocol_version":1,"payload":{"encoding":{"symbol_length":4,"type":3},"client_role":1},"type":30,"status":200}
            $json = new stdClass();
            $json->protocol_version = 1;
            $json->payload = new stdClass();
            $json->payload->encoding = new stdClass();
            $json->payload->encoding->symbol_length = 4;
            $json->payload->encoding->type = 3;
            $json->payload->client_role = 1;
            $json->type = 30;
            $json->status = 200;
            $payload = json_encode($json);
            $payloadLen = strlen($payload);
            $connection->write(getLen($payloadLen));
            $connection->write($payload);
            break;
          }
          case 31:{
            // when we arrive here, the TV screen displays a code with 4 characters
            // message to send:
            // {"protocol_version":1,"payload":{"secret":"encodedSecret"},"type":40,"status":200}
            $json = new stdClass();
            $json->protocol_version = 1;
            $json->payload = new stdClass();
            // get the code... here we'll let the user to enter it in the console
            $code = readline("Code: ");

            // get the client's certificate
            $clientPub = openssl_get_publickey(file_get_contents("client.pem"));
            $clientPubDetails = openssl_pkey_get_details($clientPub);
            // get the server's certificate
            $serverPub = openssl_get_publickey(file_get_contents("public.key"));
            $serverPubDetails = openssl_pkey_get_details($serverPub);

            // get the client's certificate modulus
            $clientModulus = $clientPubDetails['rsa']['n'];
            // get the client's certificate exponent
            $clientExponent = $clientPubDetails['rsa']['e'];
            // get the server's certificate modulus
            $serverModulus = $serverPubDetails['rsa']['n'];
            // get the server's certificate exponent
            $serverExponent = $serverPubDetails['rsa']['e'];

            // use SHA-256
            $ctxHash = hash_init('sha256');
            hash_update($ctxHash, $clientModulus);
            hash_update($ctxHash, $clientExponent);
            hash_update($ctxHash, $serverModulus);
            hash_update($ctxHash, $serverExponent);
            // only keep the last two characters of the code
            $codeBin = hex2bin(substr($code, 2));
            hash_update($ctxHash, $codeBin);
            $alpha = hash_final($ctxHash, true);
            
            // encode in base64
            $json->payload->secret = base64_encode($alpha);
            $json->type = 40;
            $json->status = 200;
            $payload = json_encode($json);
            $payloadLen = strlen($payload);

            $connection->write(getLen($payloadLen));
            $connection->write($payload);
            break;
          }
        }
      }
    }
  });

  // send the first message to the server
  // {"protocol_version":1,"payload":{"service_name":"androidtvremote","client_name":"TEST"},"type":10,"status":200}
  $json = new stdClass();
  $json->protocol_version = 1;
  $json->payload = new stdClass();
  $json->payload->service_name = "androidtvremote";
  $json->payload->client_name = "interface Web";
  $json->type = 10;
  $json->status = 200;
  $payload = json_encode($json);
  $payloadLen = strlen($payload);

  // send the message size
  $connection->write(getLen($payloadLen));
  // send the message
  $connection->write($payload);
}, 'printf');

$loop->run();
?>

Send Commands

Now that the client is paired with the server, we'll use port 6466 to send the commands.
Please, note we'll use an array of bytes for the commands.

Protocol

Before looking at how to send a command, we'll review the content of the array of bytes that will be sent.

Each message is an array of byte made of three components/parts:

  • (first) the type of the message
  • (second) a separator
  • (third) the payload

Message type

The message type is two byte length

  • (first) the length, it's always 1
  • (second) the type of the message

Separator

The separator is a 0 byte

Payload

The payload is different for each type of message but he always have the same system :

  • (first) the length of the payload
  • (second) the payload

Steps to send a command

To send a command you need:

  1. Send a configuration message
  2. Verify the ACK from the server
  3. Send the command

Step 1: Configuration message

This is the first message that must be sent before sending any command.

The message type is 0

The payload content is :

  • 1 as an 4 byte integer (2 times)
  • 32 as a byte
  • 3 as a byte
  • 0 as a byte (6 times)
  • a string device name

The device name is made by :

  1. converting the string to byte[]
  2. adding the byte[] length
  3. adding the byte[]

Here is an example of the message to be sent with a device named "test" (t=116, e=101, s=115, t=116) – I tried with something else than 'test' but it failed…:

[1,0,0,21,0,0,0,1,0,0,0,1,32,3,0,0,0,0,0,0,4,116,101,115,116]

Step 2: Configuration ACK

You need to verify the server's acknowledge.

The message type is 7, so it looks good when you receive an array of bytes that start with [1,7,0.
Followed by two more acknowledges: [1,27,0,1,0] (or [1,27,0,1,1] if you have the USB Debug mode activated) and [1,38,0,16,0,0,0,12,0,0,0,1,0,0,0,0,0,0,0,1]
.

The first payload contains several information splited with separator made of 0,0,0,0

  • A remote id (integer on 4 byte)
  • An hash (string, first byte is the length)
  • Device informations (string, first byte is the length)
  • Unkown information (string, first byte is the length)
  • Short device name (string, first byte is the length)
  • Full device name (string, first byte is the length)

Here is an exemple of response :

[1,7,0,152,0,0,0,2,0,0,0,0,40,97,100,52,53,101,98,50,51,101,51,100,99,100,55,52,49,102,100,97,48,55,99,98,101,98,97,99,100,48,54,56,54,55,56,52,51,57,55,102,51,0,0,0,0,52,70,114,101,101,98,111,120,47,102,98,120,56,97,109,47,102,98,120,56,97,109,58,57,47,80,73,47,118,57,46,54,46,50,54,58,117,115,101,114,47,114,101,108,101,97,115,101,45,107,101,121,115,0,0,0,0,2,80,73,0,0,0,0,7,70,114,101,101,98,111,120,0,0,0,0,18,70,114,101,101,98,111,120,32,80,108,97,121,101,114,32,80,79,80,0,0,0,28]

  • 1,7,0 is confirmation;
  • 152 is the message length;
  • 0,0,0,2 is the remote id;
  • 0,0,0,0 is the separator;
  • 40 represents the length of the next string;
  • 97,100,52,53,101,98,50,51,101,51,100,99,100,55,52,49,102,100,97,48,55,99,98,101,98,97,99,100,48,54,56,54,55,56,52,51,57,55,102,51 is an hash ("ad45eb23e3dcd741fda07cbebacd0686784397f3");
  • 52 represents the length of the next string;
  • 70,114,101,101,98,111,120,47,102,98,120,56,97,109,47,102,98,120,56,97,109,58,57,47,80,73,47,118,57,46,54,46,50,54,58,117,115,101,114,47,114,101,108,101,97,115,101,45,107,101,121,115 ("Freebox/fbx8am/fbx8am:9/PI/v9.6.26:user/release-keys")
  • 2 represents the length of the next string;
  • 80,73 is "PI";
  • 7 represents the length of the next string;
  • 70,114,101,101,98,111,120 is the short device name ("Freebox");
  • 18 represents the length of the next string;
  • 70,114,101,101,98,111,120,32,80,108,97,121,101,114,32,80,79,80 is the long device name ("Freebox Player POP").

Once the 3 packets received, we can proceed and send commands.

Step 3: Commands

After steps 1 and 2, it's time to send the command.

If you simulate a key press, you must send two messages to execute one command.

The format is:

[1,2,0,{SIZE=16},0,0,0,0,0,0,0, {COUNTER} ,0,0,0, {PRESS=0} ,0,0,0,{KEYCODE}]
[1,2,0,{SIZE=16},0,0,0,0,0,0,0,{COUNTER+1},0,0,0,{RELEASE=1},0,0,0,{KEYCODE}]

The {KEYCODE} can be found on https://developer.android.com/reference/android/view/KeyEvent.

For example, if we want to send a VOLUME_UP:

[1,2,0,16,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24]
[1,2,0,16,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,24]

Or if you want to send 10:

[1,2,0,16,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8]
[1,2,0,16,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,8]
[1,2,0,16,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,7]
[1,2,0,16,0,0,0,0,0,0,0,3,0,0,0,1,0,0,0,7]

It's also possible to launch an application with intent. For example, with Netflix, we send only one message:

// intent:#Intent;component=com.netflix.ninja/.MainActivity;end [1,16,0,60,105,110,116,101,110,116,58,35,73,110,116,101,110,116,59,99,111,109,112,111,110,101,110,116,61,99,111,109,46,110,101,116,102,108,105,120,46,110,105,110,106,97,47,46,77,97,105,110,65,99,116,105,118,105,116,121,59,101,110,100]

PHP Code

And here some PHP code:

<?php
use React\EventLoop\Factory;
use React\Socket\Connector;
use React\Socket\SecureConnector;
use React\Socket\ConnectionInterface;

require __DIR__ . '/./vendor/autoload.php';

$host = 'SERVER_IP';
$loop = Factory::create();
$tcpConnector = new React\Socket\TcpConnector($loop);
$dnsResolverFactory = new React\Dns\Resolver\Factory();
$dns = $dnsResolverFactory->createCached('8.8.8.8', $loop);
$dnsConnector = new React\Socket\DnsConnector($tcpConnector, $dns);

$connector = new SecureConnector($dnsConnector, $loop, array(
  'allow_self_signed' => true,
  'verify_peer' => false,
  'verify_peer_name' => false,
  'dns' => false,
  'local_cert' => 'client.pem'
));

// convert the array of bytes
function toMsg($arr) {
  $chars = array_map("chr", $arr);
  return join($chars);
}

// connect to the server
$connector->connect('tls://' . $host . ':6466')->then(function (ConnectionInterface $connection) use ($host) {
  $connection->on('data', function ($data) use ($connection) {
    // convert the data received to an array of bytes
    $dataLen = strlen($data);
    $arr = [];
    for ($i=0; $i<$dataLen;$i++) {
      $arr[] = ord($data[$i]);
    }
    $str = "[".implode(",", $arr)."]";
    //echo "data recv => ".$data." ".$str." (".strlen($data).")\n";

    // if we receive [1,20,0,0] it means the server sent a ping
    if (strpos($str, "[1,20,0,0]") === 0) {
      // we can reply with a PONG [1,21,0,0] if we want
      // $connection->write(toMsg([1,21,0,0]));
    } else if ($str === "[1,27,0,1,1,1,38,0,16,0,0,0,12,0,0,0,1,0,0,0,0,0,0,0,1]" || $str === "[1,27,0,1,0,1,38,0,16,0,0,0,12,0,0,0,1,0,0,0,0,0,0,0,1]" || $str === "[1,38,0,16,0,0,0,12,0,0,0,1,0,0,0,0,0,0,0,1]") {
      // the server confirmed everything's OK so we can send the command
      // let's send a 2 VOLUME_UP (press/release)
      $messages = [
        [ [1,2,0,16,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24], [1,2,0,16,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,24] ], // first VOLUME_UP
        [ [1,2,0,16,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,24], [1,2,0,16,0,0,0,0,0,0,0,3,0,0,0,1,0,0,0,24] ]  // second VOLUME_UP
      ];
      // we need to add delay (300ms) between each command
      $timer = 0;
      $end = count($messages) - 1;
      foreach($messages as $idx => $msg) {
        $loop->addTimer($timer, function () use ($connection, $msg, $idx, $end) {
          foreach($msg as $m) {
            // echo "data sent (".$idx."/".$end.") => [".implode(',',$m)."]";
            $connection->write(toMsg($m));
          }
          // if we reach the last message, we end the connection it
          if ($idx === $end) {
            $connection->end();
          }
        });
        $timer += 0.3;
      }
    }
  });

  // send the first message (configuration) to the server
  $arr = [1,0,0,21,0,0,0,1,0,0,0,1,32,3,0,0,0,0,0,0,4,116,101,115,116];
  $connection->write(toMsg($arr));
}, 'printf');

$loop->run();
?>
Clone this wiki locally