{
    "type": "article",
    "title": "A TV remote from the command line",
    "canonical": "https://www.dealmobile.co.uk/writing/a-tv-remote-from-the-command-line",
    "path": "/writing/a-tv-remote-from-the-command-line",
    "url": "https://www.dealmobile.co.uk/writing/a-tv-remote-from-the-command-line",
    "markdown": "https://www.dealmobile.co.uk/writing/a-tv-remote-from-the-command-line.md",
    "json": "https://www.dealmobile.co.uk/writing/a-tv-remote-from-the-command-line.json",
    "summary": "Pairing a Linux computer with a Google TV over the protocol phone remotes use, what the six-character code on the screen really checks, and why a remote that can’t change anything is worth having next to ADB.",
    "published": "2026-10-23T00:00:00+01:00",
    "modified": "2026-10-25T18:32:07+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/a-tv-remote-from-the-command-line/hero.png",
        "alt": "The TV’s pairing screen, asking for the code DAE61C to be entered on the tcl_tv PC."
    },
    "fields": {
        "image_light": null
    },
    "tags": [
        {
            "name": "Android",
            "url": "https://www.dealmobile.co.uk/writing/tag/android",
            "json": "https://www.dealmobile.co.uk/writing/tag/android.json"
        },
        {
            "name": "Python",
            "url": "https://www.dealmobile.co.uk/writing/tag/python",
            "json": "https://www.dealmobile.co.uk/writing/tag/python.json"
        },
        {
            "name": "TCL Google TV Tools",
            "url": "https://www.dealmobile.co.uk/writing/tag/tcl-google-tv-tools",
            "json": "https://www.dealmobile.co.uk/writing/tag/tcl-google-tv-tools.json"
        }
    ],
    "projects": [
        {
            "name": "TCL Google TV Tools",
            "url": "https://www.dealmobile.co.uk/projects/tcl-google-tv-tools",
            "json": "https://www.dealmobile.co.uk/projects/tcl-google-tv-tools.json"
        }
    ],
    "body": {
        "html": "<p>Everything in <a href=\"/writing/decluttering-a-tcl-google-tv\">decluttering our TCL Google TV</a> went through ADB, Android’s debug bridge. ADB can do anything: run a shell, switch packages off, change settings, restart the TV. It also has to be switched on in Developer options, and at the end of the project it should be switched off again. Google TV has a second way in that’s always on, because the phone remote apps use it. It presses keys, opens apps and reports what the TV is doing, and it can’t do anything else. <code>tv-remote.py</code> is about 100 lines of Python that pair with it. This is how it works.</p>\n<h2 id=\"two-ways-in\">Two ways in</h2>\n<p>The TV announces both over mDNS, as <code>_androidtvremote2._tcp</code> and <code>_googlecast._tcp</code>, which is how this project found it in the first place. A port scan before anything was switched on showed 6466 and 6467 open for the remote, 8008, 8009 and 8443 for Cast, and 5555, ADB’s port, closed until USB debugging went on.</p>\n<figure class=\"shot shot--prose\"><img class=\"theme-img theme-img--light\" src=\"/sites/default/files/styles/almanac_shot/public/content/images/posts/a-tv-remote-from-the-command-line/two-ways-in.png.webp?itok=ZpNZ6vpL\" srcset=\"/sites/default/files/styles/almanac_shot_md/public/content/images/posts/a-tv-remote-from-the-command-line/two-ways-in.png.webp?itok=SjL_KMAE 1200w, /sites/default/files/styles/almanac_shot/public/content/images/posts/a-tv-remote-from-the-command-line/two-ways-in.png.webp?itok=ZpNZ6vpL 2240w\" sizes=\"(width &gt;= 39em) 37rem, calc(100vw - 2rem)\" alt=\"Two panels. ADB, on port 5555, is switched on through Developer options and USB debugging, and can run a shell, disable packages, change settings, read the system log, take screenshots and restart the TV. The remote protocol, on ports 6467 and 6466, is on for phone remotes, and can press remote keys, open an app by link and read the power, volume and current app, but can’t change settings or run commands.\" width=\"2240\" height=\"1608\" loading=\"lazy\" decoding=\"async\"><img class=\"theme-img theme-img--dark\" src=\"/sites/default/files/styles/almanac_shot/public/content/images/posts/a-tv-remote-from-the-command-line/two-ways-in-dark.png.webp?itok=gNB_KQxH\" srcset=\"/sites/default/files/styles/almanac_shot_md/public/content/images/posts/a-tv-remote-from-the-command-line/two-ways-in-dark.png.webp?itok=CGDWBakQ 1200w, /sites/default/files/styles/almanac_shot/public/content/images/posts/a-tv-remote-from-the-command-line/two-ways-in-dark.png.webp?itok=gNB_KQxH 2240w\" sizes=\"(width &gt;= 39em) 37rem, calc(100vw - 2rem)\" alt=\"Two panels. ADB, on port 5555, is switched on through Developer options and USB debugging, and can run a shell, disable packages, change settings, read the system log, take screenshots and restart the TV. The remote protocol, on ports 6467 and 6466, is on for phone remotes, and can press remote keys, open an app by link and read the power, volume and current app, but can’t change settings or run commands.\" width=\"2240\" height=\"1608\" loading=\"lazy\" decoding=\"async\"><figcaption>Two ways into the same TV. The narrower one is always there.</figcaption></figure>\n<p>The difference matters for anything automated. While reading other people’s projects for this one, I found two servers that let an AI agent drive a TV. One works over the remote protocol and offers ten tools: keys, text, opening apps and status. The other works over ADB and offers a shell, a full uninstall and a restart. The first can annoy whoever is watching. The second can break the TV.</p>\n<h2 id=\"pairing\">Pairing</h2>\n<p>The remote protocol is two services over TLS. Port 6467 pairs, and port 6466 takes commands once paired. Both use protocol buffers. The Python library androidtvremote2, which Home Assistant also uses, implements the client, so the script only has to manage a certificate and pass the code along:</p>\n<pre><code class=\"language-python\">r = AndroidTVRemote(\"tcl_tv PC\", str(CERT), str(KEY), TV, enable_ime=False)\nawait r.async_generate_cert_if_missing()\nawait r.async_start_pairing()\ncode = input(\"Code: \").strip()\nawait r.async_finish_pairing(code)\n</code></pre>\n<p>The client makes itself a self-signed certificate and asks to pair, and the TV shows a six-character code. The code looks like a PIN, but only four of its characters are random. The first two are a check. The client hashes both certificates’ public keys, the laptop’s and the TV’s, together with the last four characters, and the first byte of that SHA-256 hash has to equal the first two:</p>\n<pre><code class=\"language-python\">h = hashlib.sha256()\nh.update(bytes.fromhex(f\"{client_modulus:X}\"))\nh.update(bytes.fromhex(f\"0{client_exponent:X}\"))\nh.update(bytes.fromhex(f\"{server_modulus:X}\"))\nh.update(bytes.fromhex(f\"0{server_exponent:X}\"))\nh.update(bytes.fromhex(pairing_code[2:]))\nif h.digest()[0] != int(pairing_code[0:2], 16): ...\n</code></pre>\n<figure class=\"shot shot--prose\"><img class=\"theme-img theme-img--light\" src=\"/sites/default/files/styles/almanac_shot/public/content/images/posts/a-tv-remote-from-the-command-line/pairing-code.png.webp?itok=YQ0fk4fy\" srcset=\"/sites/default/files/styles/almanac_shot_md/public/content/images/posts/a-tv-remote-from-the-command-line/pairing-code.png.webp?itok=g4s1gsOo 1200w, /sites/default/files/styles/almanac_shot/public/content/images/posts/a-tv-remote-from-the-command-line/pairing-code.png.webp?itok=YQ0fk4fy 2240w\" sizes=\"(width &gt;= 39em) 37rem, calc(100vw - 2rem)\" alt=\"A six-character pairing code, DA E61C, split into a two-character check and four random characters. The random characters go into a SHA-256 hash with the laptop’s and the TV’s certificate keys. The hash’s first byte must equal the check, and the whole hash goes to the TV as the secret.\" width=\"2240\" height=\"1512\" loading=\"lazy\" decoding=\"async\"><img class=\"theme-img theme-img--dark\" src=\"/sites/default/files/styles/almanac_shot/public/content/images/posts/a-tv-remote-from-the-command-line/pairing-code-dark.png.webp?itok=34k0xA7k\" srcset=\"/sites/default/files/styles/almanac_shot_md/public/content/images/posts/a-tv-remote-from-the-command-line/pairing-code-dark.png.webp?itok=WhK_difN 1200w, /sites/default/files/styles/almanac_shot/public/content/images/posts/a-tv-remote-from-the-command-line/pairing-code-dark.png.webp?itok=34k0xA7k 2240w\" sizes=\"(width &gt;= 39em) 37rem, calc(100vw - 2rem)\" alt=\"A six-character pairing code, DA E61C, split into a two-character check and four random characters. The random characters go into a SHA-256 hash with the laptop’s and the TV’s certificate keys. The hash’s first byte must equal the check, and the whole hash goes to the TV as the secret.\" width=\"2240\" height=\"1512\" loading=\"lazy\" decoding=\"async\"><figcaption>The code from the pairing screen at the top, split in two. The check ties it to the two certificates on that connection.</figcaption></figure>\n<p>A mistyped code fails that check before anything is sent, and so does a connection where something in the middle has swapped in its own certificate, because the TV worked out the check from the certificate it actually presented. The whole hash then goes to the TV as the secret, and the TV does the same sum. After that, the certificate is the credential. Whoever holds the key file can drive the TV, so the script keeps it in a folder only its owner can read.</p>\n<h2 id=\"reading-the-code-without-being-there\">Reading the code without being there</h2>\n<p>Someone normally reads the code off the TV and types it in. With ADB still on, nobody had to. The pairing ran in the background, waiting up to three minutes for the code to appear in a file, and a screenshot over ADB supplied it:</p>\n<pre><code class=\"language-bash\">python3 scripts/tv-remote.py pair --code-file code.txt &amp;\nadb exec-out screencap -p &gt; screen.png\necho DAE61C &gt; code.txt\n</code></pre>\n<p>Screenshots in this project are deleted once they’ve been read. The pairing screen at the top of this post is kept because it belonged to a throwaway pairing with its own certificate, cancelled before it finished.</p>\n<h2 id=\"keys-apps-and-the-tvs-state\">Keys, apps and the TV’s state</h2>\n<p>Once paired, <code>status</code> reads what the TV tells a remote when it connects:</p>\n<pre><code>$ python3 scripts/tv-remote.py status\non=True app= volume={'level': 86, 'max': 100, 'muted': False} device={'manufacturer': 'TCL', 'model': 'Smart TV Pro', 'sw_version': '7.00.956317615'}\n</code></pre>\n<p>The current app came back empty while Projectivy’s home screen was showing, so it isn’t a reliable way to tell what’s on.</p>\n<p><code>key</code> sends key names from the protocol, one after another. <code>DPAD_DOWN</code> moved the focus from the Fire Stick tile into the row of apps, twelve presses of <code>DPAD_LEFT</code> scrolled the row back to its start, and <code>HOME</code> brought Projectivy back from another app. The script also takes Android key codes as numbers, such as 243 for HDMI 1, but I haven’t tried one.</p>\n<p><code>app</code> hands the TV a link, and whichever app claims it opens. A Play Store link for Projectivy opened the Play Store on Projectivy’s page, and <code>HOME</code> closed it again.</p>\n<h2 id=\"what-it-misses\">What it misses</h2>\n<p>It can’t change a setting, read the screen or install anything, which is the point, and also why it can’t replace ADB for the work in the decluttering post. It only works while the TV is on the network. On the night the TV lost power, the remote port went silent along with ADB and everything else.</p>\n<p>The first two presses I sent went to a TV that had been left alone, with Projectivy’s home screen faded down to its wallpaper, and they didn’t bring it back. The same two presses over ADB did, and once the home screen was showing, every press over the remote protocol worked. I don’t know why. Whether the remote protocol can wake the TV from standby, I haven’t tried.</p>\n<h2 id=\"using-it\">Using it</h2>\n<p>The script needs androidtvremote2, in <code>requirements.txt</code>, and the TV’s address:</p>\n<pre><code class=\"language-bash\">pip install -r requirements.txt\nexport TV_HOST=192.168.1.50\npython3 scripts/tv-remote.py pair      # type the code shown on the TV\npython3 scripts/tv-remote.py status\npython3 scripts/tv-remote.py key HOME\n</code></pre>\n<p>The pairing key is saved in <code>~/.config/tcl_tv/remote/</code>. Keep it private. The script is in the <a href=\"/projects/tcl-google-tv-tools\">TCL Google TV Tools</a> repository.</p>"
    },
    "markdown_text": "---\ntitle: 'A TV remote from the command line'\ncanonical: 'https://www.dealmobile.co.uk/writing/a-tv-remote-from-the-command-line'\nurl: 'https://www.dealmobile.co.uk/writing/a-tv-remote-from-the-command-line'\ntype: Post\npublished: '2026-10-23'\nsummary: 'Pairing a Linux computer with a Google TV over the protocol phone remotes use, what the six-character code on the screen really checks, and why a remote that can’t change anything is worth having next to ADB.'\n---\n\n# A TV remote from the command line\n\nPublished 2026-10-23\n\n![The TV’s pairing screen, asking for the code DAE61C to be entered on the tcl_tv PC.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/a-tv-remote-from-the-command-line/hero.png.webp?itok=zrbYeYpv)\n\nEverything in [decluttering our TCL Google TV](https://www.dealmobile.co.uk/writing/decluttering-a-tcl-google-tv.md) went through ADB, Android’s debug bridge. ADB can do anything: run a shell, switch packages off, change settings, restart the TV. It also has to be switched on in Developer options, and at the end of the project it should be switched off again. Google TV has a second way in that’s always on, because the phone remote apps use it. It presses keys, opens apps and reports what the TV is doing, and it can’t do anything else. `tv-remote.py` is about 100 lines of Python that pair with it. This is how it works.\n\n## Two ways in\n\nThe TV announces both over mDNS, as `_androidtvremote2._tcp` and `_googlecast._tcp`, which is how this project found it in the first place. A port scan before anything was switched on showed 6466 and 6467 open for the remote, 8008, 8009 and 8443 for Cast, and 5555, ADB’s port, closed until USB debugging went on.\n\n![Two panels. ADB, on port 5555, is switched on through Developer options and USB debugging, and can run a shell, disable packages, change settings, read the system log, take screenshots and restart the TV. The remote protocol, on ports 6467 and 6466, is on for phone remotes, and can press remote keys, open an app by link and read the power, volume and current app, but can’t change settings or run commands.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot/public/content/images/posts/a-tv-remote-from-the-command-line/two-ways-in.png.webp?itok=ZpNZ6vpL)\n\n*Two ways into the same TV. The narrower one is always there.*\n\nThe difference matters for anything automated. While reading other people’s projects for this one, I found two servers that let an AI agent drive a TV. One works over the remote protocol and offers ten tools: keys, text, opening apps and status. The other works over ADB and offers a shell, a full uninstall and a restart. The first can annoy whoever is watching. The second can break the TV.\n\n## Pairing\n\nThe remote protocol is two services over TLS. Port 6467 pairs, and port 6466 takes commands once paired. Both use protocol buffers. The Python library androidtvremote2, which Home Assistant also uses, implements the client, so the script only has to manage a certificate and pass the code along:\n\n```python\nr = AndroidTVRemote(\"tcl_tv PC\", str(CERT), str(KEY), TV, enable_ime=False)\nawait r.async_generate_cert_if_missing()\nawait r.async_start_pairing()\ncode = input(\"Code: \").strip()\nawait r.async_finish_pairing(code)\n\n```\n\nThe client makes itself a self-signed certificate and asks to pair, and the TV shows a six-character code. The code looks like a PIN, but only four of its characters are random. The first two are a check. The client hashes both certificates’ public keys, the laptop’s and the TV’s, together with the last four characters, and the first byte of that SHA-256 hash has to equal the first two:\n\n```python\nh = hashlib.sha256()\nh.update(bytes.fromhex(f\"{client_modulus:X}\"))\nh.update(bytes.fromhex(f\"0{client_exponent:X}\"))\nh.update(bytes.fromhex(f\"{server_modulus:X}\"))\nh.update(bytes.fromhex(f\"0{server_exponent:X}\"))\nh.update(bytes.fromhex(pairing_code[2:]))\nif h.digest()[0] != int(pairing_code[0:2], 16): ...\n\n```\n\n![A six-character pairing code, DA E61C, split into a two-character check and four random characters. The random characters go into a SHA-256 hash with the laptop’s and the TV’s certificate keys. The hash’s first byte must equal the check, and the whole hash goes to the TV as the secret.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot/public/content/images/posts/a-tv-remote-from-the-command-line/pairing-code.png.webp?itok=YQ0fk4fy)\n\n*The code from the pairing screen at the top, split in two. The check ties it to the two certificates on that connection.*\n\nA mistyped code fails that check before anything is sent, and so does a connection where something in the middle has swapped in its own certificate, because the TV worked out the check from the certificate it actually presented. The whole hash then goes to the TV as the secret, and the TV does the same sum. After that, the certificate is the credential. Whoever holds the key file can drive the TV, so the script keeps it in a folder only its owner can read.\n\n## Reading the code without being there\n\nSomeone normally reads the code off the TV and types it in. With ADB still on, nobody had to. The pairing ran in the background, waiting up to three minutes for the code to appear in a file, and a screenshot over ADB supplied it:\n\n```bash\npython3 scripts/tv-remote.py pair --code-file code.txt &\nadb exec-out screencap -p > screen.png\necho DAE61C > code.txt\n\n```\n\nScreenshots in this project are deleted once they’ve been read. The pairing screen at the top of this post is kept because it belonged to a throwaway pairing with its own certificate, cancelled before it finished.\n\n## Keys, apps and the TV’s state\n\nOnce paired, `status` reads what the TV tells a remote when it connects:\n\n```\n$ python3 scripts/tv-remote.py status\non=True app= volume={'level': 86, 'max': 100, 'muted': False} device={'manufacturer': 'TCL', 'model': 'Smart TV Pro', 'sw_version': '7.00.956317615'}\n\n```\n\nThe current app came back empty while Projectivy’s home screen was showing, so it isn’t a reliable way to tell what’s on.\n\n`key` sends key names from the protocol, one after another. `DPAD_DOWN` moved the focus from the Fire Stick tile into the row of apps, twelve presses of `DPAD_LEFT` scrolled the row back to its start, and `HOME` brought Projectivy back from another app. The script also takes Android key codes as numbers, such as 243 for HDMI 1, but I haven’t tried one.\n\n`app` hands the TV a link, and whichever app claims it opens. A Play Store link for Projectivy opened the Play Store on Projectivy’s page, and `HOME` closed it again.\n\n## What it misses\n\nIt can’t change a setting, read the screen or install anything, which is the point, and also why it can’t replace ADB for the work in the decluttering post. It only works while the TV is on the network. On the night the TV lost power, the remote port went silent along with ADB and everything else.\n\nThe first two presses I sent went to a TV that had been left alone, with Projectivy’s home screen faded down to its wallpaper, and they didn’t bring it back. The same two presses over ADB did, and once the home screen was showing, every press over the remote protocol worked. I don’t know why. Whether the remote protocol can wake the TV from standby, I haven’t tried.\n\n## Using it\n\nThe script needs androidtvremote2, in `requirements.txt`, and the TV’s address:\n\n```bash\npip install -r requirements.txt\nexport TV_HOST=192.168.1.50\npython3 scripts/tv-remote.py pair      # type the code shown on the TV\npython3 scripts/tv-remote.py status\npython3 scripts/tv-remote.py key HOME\n\n```\n\nThe pairing key is saved in `~/.config/tcl_tv/remote/`. Keep it private. The script is in the [TCL Google TV Tools](https://www.dealmobile.co.uk/projects/tcl-google-tv-tools.md) repository.\n\n- **Projects:** [TCL Google TV Tools](https://www.dealmobile.co.uk/projects/tcl-google-tv-tools.md)\n- **Tags:** [Android](https://www.dealmobile.co.uk/writing/tag/android.md), [Python](https://www.dealmobile.co.uk/writing/tag/python.md), [TCL Google TV Tools](https://www.dealmobile.co.uk/writing/tag/tcl-google-tv-tools.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}