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.
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
keyeventover 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.
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
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.