All Projects
Completed

Home Theatre Remote: One App for Three Devices

Replacing three remotes with a single Raspberry Pi web app — IR blasting for the projector and speaker via Broadlink, ADB over WiFi for the NVIDIA Shield.

Python Flask Broadlink ADB Raspberry Pi JavaScript PWA Home Automation IoT Raspberry Pi IR ADB

The problem

My home theatre setup has three separate remotes: one for the projector, one for the speaker, and one for the NVIDIA Shield. Every movie night involved hunting for all three, powering things on in the right order, and hoping nobody sat on the projector remote. It was annoying enough that I decided to fix it.

The goal was a single interface — ideally on my phone — that could control everything. The challenge was that the projector and speaker only speak IR, while the Shield is an Android box that doesn’t have an IR receiver at all.


The approach

The two communication methods required completely different stacks:

  • Projector and speaker — IR only. I used a Broadlink RM IR blaster connected to the local network. The Broadlink can learn and replay IR codes from any existing remote, so I taught it all the codes I needed and stored them as hex strings.
  • NVIDIA Shield — no IR receiver, but it runs Android and has ADB (Android Debug Bridge) enabled over WiFi. Sending a navigation command is as simple as firing a keyevent over TCP.

A Flask API running on a Raspberry Pi sits in the middle and routes each command to the right transport. The frontend is a plain HTML/JS web app that talks to the Flask API — and because it has a PWA manifest and service worker, it installs on the home screen like a native app.

Home theatre remote architecture diagram Phone / Browser Web remote UI PWA installed HTTP POST /api/send_ir Flask API Raspberry Pi IR command? → Broadlink ADB command? → Shield send_data() hex IR code ADB TCP keyevent Broadlink RM IR blaster python-broadlink IR signal Projector power + source Speaker power + volume NVIDIA Shield 192.168.1.104:5555 ADB over WiFi TV / Shield UI nav + power ALL DEVICES ON LOCAL WIFI NETWORK
The Pi runs Flask locally. IR commands go through the Broadlink RM; Shield navigation goes over ADB via WiFi.

Capturing IR codes

The Broadlink python-broadlink library lets you put the device into learning mode and capture the raw IR signal from any remote:

import broadlink

devices = broadlink.discover(timeout=5)
device  = devices[0]
device.auth()

device.enter_learning()   # hold original remote at the blaster
time.sleep(5)
ir_code = device.check_data()
print(ir_code.hex())      # paste this into IR_CODES dict

Once captured, replaying a code is a single call:

IR_CODES = {
    'projector_power_on':  '2600d800000120901236...',  # hex from capture
    'projector_power_off': '26006801000120901236...',
    'speaker_power_on':    '2600700000011d91143613...',
    'volume_up':           '2600600000011e90143613...',
    # ...
}

ir_code = bytes.fromhex(IR_CODES[command])
device.send_data(ir_code)

Controlling the Shield with ADB

The Shield’s ADB daemon listens on TCP port 5555. Once paired, every navigation command is just a shell keyevent — no IR required:

import subprocess

SHIELD_KEYCODES = {
    'nav_up':    19,
    'nav_down':  20,
    'nav_left':  21,
    'nav_right': 22,
    'nav_ok':    23,
    'nav_back':  3,
}

def adb(command: str) -> str:
    result = subprocess.run(
        f"adb {command}",
        shell=True,
        capture_output=True,
        text=True,
    )
    return result.stdout

# Connect once at startup
adb("connect 192.168.1.104:5555")

# Send a keypress
def send_shield_key(command: str):
    keycode = SHIELD_KEYCODES[command]
    adb(f"shell input keyevent {keycode}")

Power is a special case — keyevent 26 is the Android power button, which toggles the Shield on and off without needing to know its current state.


The Flask API

The /api/send_ir endpoint handles all commands and dispatches to the right transport:

@app.route('/api/send_ir', methods=['POST'])
def send_ir():
    command = request.json.get('command')

    # NVIDIA Shield — power toggle
    if command == 'nvidia_power':
        adb("shell input keyevent 26")
        return jsonify({'status': 'success'})

    # NVIDIA Shield — navigation
    if command in SHIELD_KEYCODES:
        adb(f"shell input keyevent {SHIELD_KEYCODES[command]}")
        return jsonify({'status': 'success'})

    # Projector / speaker — IR via Broadlink
    if command in IR_CODES:
        ir_code = bytes.fromhex(IR_CODES[command])
        device.send_data(ir_code)
        return jsonify({'status': 'success'})

    return jsonify({'error': 'Invalid command'}), 400

The frontend calls this with a simple fetch POST for every button press, with a 200ms visual flash on the button so you know the command went through:

async function sendIRCommand(command) {
    const response = await fetch('/api/send_ir', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ command }),
    });

    if (!response.ok) {
        const result = await response.json();
        throw new Error(result.error);
    }
}

// Button handler — flash active class for tactile feedback
button.addEventListener('click', async () => {
    button.classList.add('active');
    await sendIRCommand(command);
    setTimeout(() => button.classList.remove('active'), 200);
});

Try the demo

The remote below is a live simulation — click any button to see which command fires and whether it goes over IR or ADB.

Interactive — Web Remote Demo

Click any button to see the command that would be sent to the Pi.

Power

Volume

Navigate (Shield)

Command log

Waiting for input…

PWA — installing it on your phone

Adding a manifest.json and service worker turns the web app into a Progressive Web App, so it installs to the home screen and opens full-screen with no browser chrome:

{
  "name": "Home Theatre Remote",
  "short_name": "Remote",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0f0f0f",
  "theme_color": "#0f0f0f",
  "icons": [{ "src": "/icon.png", "sizes": "192x192", "type": "image/png" }]
}

With that in place, Android shows an “Add to Home Screen” prompt automatically. The result is an icon on the home screen that opens the remote in full-screen — indistinguishable from a native app.


What I learned

IR capture is fussier than it looks. The raw hex codes are long and any noise during capture produces a subtly wrong code that either doesn’t work or works intermittently. The double-capture verification step saved a lot of debugging time.

ADB over WiFi is surprisingly reliable. I expected flaky reconnections, but once the static IP was set and the initial adb connect ran at Flask startup, it just stayed up. The auto-reconnect logic in the endpoint handles the rare drop gracefully.

One Flask endpoint handles everything cleanly. Routing IR vs. ADB in a single /api/send_ir endpoint kept the frontend dead simple — it doesn’t need to know which transport a command uses, just the command name.