-
-
Notifications
You must be signed in to change notification settings - Fork 14
Google TV (aka Android TV) Remote Control
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
📣 You need to activate the USB Debug
in the Developer Options
menu of the Android TV.
The pairing protocol will happen on port 6467.
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.
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();
?>
- (first) we send the length of the message (JSON string) on 4 bytes,
- (second) we send the message (JSON string) itself.
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}
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
.
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!
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}
(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();
?>
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.
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
The message type is two byte length
- (first) the length, it's always 1
- (second) the type of the message
The separator is a 0 byte
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
To send a command you need:
- Send a configuration message
- Verify the ACK from the server
- Send the command
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 :
- converting the string to byte[]
- adding the byte[] length
- 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]
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,1]
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.
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]
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,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();
?>