Building a reliable remote control Raspberry Pi setup requires more than just plugging in an infrared sensor and hoping for the best. To control high-current loads like lights, motors, or HVAC contactors, you need microsecond-accurate pulse decoding and proper galvanic isolation. This guide walks you through wiring a 38kHz IR receiver to a Raspberry Pi 4 Model B, driving an opto-isolated relay module safely, and decoding the NEC IR protocol using Python and the pigpio library.

Target Board: This code and wiring diagram specifically target the Raspberry Pi 4 Model B (Rev 1.5, 4GB RAM). The Pi 5 uses the RP1 chip with different GPIO memory mapping, which requires updated library bindings not covered in this baseline NEC decoder script.

Project Overview & Hardware Specifications

The biggest mistake hobbyists make with Pi-based IR projects is underestimating the timing jitter of standard Linux GPIO polling. The OS kernel can delay a Python script by milliseconds, completely mangling an IR pulse train. We solve this by offloading the timing to the pigpio daemon, which samples the GPIO ring buffer in C.

Below is the exact bill of materials (BOM) and electrical specifications required for this build. Do not substitute the TSOP38238 with a generic 5V-only receiver; the Pi's GPIO pins are strictly 3.3V tolerant.

Component Exact Model / Variant Operating Voltage Logic Level Est. Price (2026)
Microcontroller Raspberry Pi 4 Model B (Rev 1.5, 4GB) 5V / 3A (USB-C) 3.3V $55.00
IR Receiver TSOP38238 (Vishay Semiconductors) 2.5V - 5.5V 3.3V Output $1.50
Relay Module 4-Channel Opto-Isolated (JD-VCC Jumper) 5V Coil 3.3V Trigger $6.50
NPN Transistor 2N2222A (TO-92 Package) N/A 3.3V Base Drive $0.10
Flyback Diode 1N4007 (or built-in module diode) 1000V PIV N/A $0.05

Wiring the IR Receiver and Relay Module

Driving a 5V relay coil directly from a 3.3V Raspberry Pi GPIO pin is a fast track to frying the SoC. Even if the relay module has an optocoupler, the internal LED often requires more current than the Pi can safely source, and back-EMF spikes can jump the isolation barrier if the module is cheaply designed. We use a 2N2222A transistor as a low-side switch to drive the relay optocoupler.

Pin Mapping Table

Pi 4 GPIO (BCM) Physical Pin Destination Function
GPIO 17 11 TSOP38238 OUT IR Pulse Input
GPIO 27 13 Transistor 1 Base (via 1kΩ) Relay 1 Trigger
GPIO 22 15 Transistor 2 Base (via 1kΩ) Relay 2 Trigger
GPIO 23 16 Transistor 3 Base (via 1kΩ) Relay 3 Trigger
GPIO 24 18 Transistor 4 Base (via 1kΩ) Relay 4 Trigger
3.3V Power 1 TSOP38238 VCC Sensor Power
5V Power 2 Relay Module JD-VCC Relay Coil Power
GND 6, 9, 14, 20 All Modules & Emitters Common Ground

Step-by-Step Wiring Procedure

  1. Prepare the Relay Module: Locate the jumper labeled VCC JD-VCC on your relay module. Remove this jumper. This is critical. It separates the logic side (optocoupler LEDs) from the coil side (relay electromagnets). Connect Pi 5V to the JD-VCC pin, and Pi GND to the module GND.
  2. Wire the IR Receiver: Connect the TSOP38238 VCC to Pi 3.3V, GND to Pi GND, and OUT to GPIO 17. Add a 4.7µF capacitor across VCC and GND on the breadboard to filter out power rail noise from the Pi's switching regulator.
  3. Build the Transistor Drivers: For each relay channel, connect the Pi GPIO pin to the base of a 2N2222A transistor through a 1kΩ resistor. Connect the transistor emitter to GND. Connect the transistor collector to the relay module's input pin (e.g., IN1).
  4. Verify with a Multimeter: Before applying 5V to the relay coils, use your multimeter to check for continuity between the transistor collector and the relay input pin. Ensure there are no shorts to the 3.3V rail.

Python Control Code with Error Handling

This script uses the pigpio library to capture raw GPIO state changes and decode the NEC IR protocol. The NEC protocol uses a 9ms leading pulse burst followed by a 4.5ms space, then 32 bits of data (address, inverted address, command, inverted command).

Prerequisite: Install the daemon via terminal using sudo apt install pigpio python3-pigpio and start it with sudo systemctl enable pigpiod && sudo systemctl start pigpiod.

import pigpio
import time

# --- PIN DEFINITIONS (BCM Numbering) ---
IR_PIN = 17
RELAY_PINS = [27, 22, 23, 24]

# Map specific NEC remote hex codes to relay indices
# Change these hex values to match your specific remote's output
REMOTE_MAP = {
    0x45: 0,  # Power/CH- button -> Relay 1
    0x46: 1,  # Mode button       -> Relay 2
    0x47: 2,  # Mute button       -> Relay 3
    0x44: 3   # Play/Pause        -> Relay 4
}

class NECDecoder:
    def __init__(self, pi, gpio, callback):
        self.pi = pi
        self.gpio = gpio
        self.callback = callback
        self.last_tick = None
        self.pulses = []
        
        # Set up hardware pull-up and glitch filter (100us)
        self.pi.set_mode(gpio, pigpio.INPUT)
        self.pi.set_pull_up_down(gpio, pigpio.PUD_UP)
        self.pi.set_glitch_filter(gpio, 100)
        
        self.cb = self.pi.callback(gpio, pigpio.EITHER_EDGE, self._cb)

    def _cb(self, gpio, level, tick):
        if self.last_tick is not None:
            # Calculate pulse duration in microseconds
            diff = pigpio.tickDiff(self.last_tick, tick)
            self.pulses.append((level, diff))
            
            # If we have enough pulses, attempt decode
            if len(self.pulses) >= 67: # 1 leader + 32 bits * 2 transitions
                self._decode()
                self.pulses = []
                
        self.last_tick = tick

    def _decode(self):
        # Verify 9ms leader pulse and 4.5ms space
        if self.pulses[0][0] == 0 and self.pulses[0][1] < 8000: return
        if self.pulses[1][0] == 1 and self.pulses[1][1] < 4000: return
        
        command = 0
        for i in range(32):
            # Each bit is a 562.5us pulse + variable space
            # Space ~562us = 0, Space ~1687us = 1
            space_len = self.pulses[3 + (i * 2)][1]
            if space_len > 1000:
                command |= (1 << i)
                
        # Extract actual 8-bit command (bits 16-23 in NEC format)
        cmd_byte = (command >> 16) & 0xFF
        self.callback(cmd_byte)

def handle_command(cmd):
    print(f"Received IR Command: 0x{cmd:02X}")
    if cmd in REMOTE_MAP:
        relay_idx = REMOTE_MAP[cmd]
        pin = RELAY_PINS[relay_idx]
        # Toggle logic: Read current state, invert it
        current_state = pi.read(pin)
        pi.write(pin, not current_state)
        print(f"Toggled Relay {relay_idx + 1} (GPIO {pin}) to {'ON' if not current_state else 'OFF'}")

if __name__ == "__main__":
    try:
        pi = pigpio.pi()
        if not pi.connected:
            raise ConnectionError("Failed to connect to pigpio daemon.")
            
        # Initialize Relay Pins as Outputs (Default OFF / LOW)
        for pin in RELAY_PINS:
            pi.set_mode(pin, pigpio.OUTPUT)
            pi.write(pin, 0)
            
        print("IR Receiver Active. Waiting for NEC remote commands...")
        decoder = NECDecoder(pi, IR_PIN, handle_command)
        
        # Keep main thread alive
        while True:
            time.sleep(1)
            
    except ConnectionError as e:
        print(f"[ERROR] {e}")
        print("Fix: Run 'sudo systemctl start pigpiod' in terminal.")
    except RuntimeError as e:
        print(f"[ERROR] {e}")
    except KeyboardInterrupt:
        print("\nShutting down gracefully...")
    finally:
        if 'pi' in locals() and pi.connected:
            for pin in RELAY_PINS:
                pi.write(pin, 0) # Ensure relays are OFF on exit
            pi.stop()

Debugging: First Three Things to Check When It Fails

When your remote control Raspberry Pi build fails to trigger, don't start rewriting code immediately. Hardware and daemon states are the usual culprits. Here are the first three things to check, ranked by frequency.

1. The pigpio Daemon is Not Running

Exact Error String: ConnectionError: Failed to connect to pigpio daemon. (or pigpio.error: failed to connect to localhost:8888)

The Cause: The Python pigpio module is just a client. It requires the C-based pigpiod background service to actually sample the GPIO hardware. If the service crashed or wasn't enabled on boot, the socket connection fails.

The Fix: Open your terminal and run sudo systemctl status pigpiod. If it's inactive, start it with sudo systemctl start pigpiod. To make it permanent, run sudo systemctl enable pigpiod.

2. Memory Access Permissions Denied

Exact Error String: RuntimeError: No access to /dev/mem. Try running as root!

The Cause: You are likely using an older version of the RPi.GPIO library alongside pigpio, or your user account is not in the gpio group. Direct memory mapping to the BCM2711 peripheral addresses requires elevated privileges.

The Fix: Add your user to the gpio group via sudo usermod -aG gpio $USER, then log out and log back in. Alternatively, run the script with sudo python3 ir_control.py, though fixing the group permissions is the cleaner, more secure approach for Raspberry Pi GPIO management.

3. Ambient Light Interference (Garbage Decodes)

Symptom: The script runs without errors, but prints random hex codes or fails to trigger when you press the remote.

The Cause: Compact fluorescent (CFL) bulbs and some LED drivers emit infrared noise in the 30-40kHz range, which blinds the TSOP38238 sensor. Furthermore, direct sunlight will completely saturate the photodiode.

The Fix: Check the serial output. If you see a flood of random commands, move the receiver away from light sources. If the problem persists, place a piece of dark red transparent plastic (or an old floppy disk shutter) over the receiver to act as an optical bandpass filter, blocking visible light while passing IR.

Extending and Simplifying the Build

Once you have the baseline remote control Raspberry Pi relay box working, you can adapt it to fit different project constraints.

How to Simplify: Swap to 433MHz RF

If line-of-sight is an issue (e.g., the Pi is hidden inside a metal project box or behind a wall), IR is the wrong tool. Simplify the physical layer by swapping the TSOP38238 for a 433MHz RF receiver module (like the SYN480R) and using a cheap 4-button RF keyfob. The RF protocol is usually a simple fixed-length PWM stream that can be decoded with the rpi-rf Python library, eliminating the need for the complex NEC leader-pulse logic.

How to Extend: MQTT and Home Assistant Integration

To turn this from a standalone box into a smart home node, extend the Python script to publish state changes to an MQTT broker.

  1. Install the Paho MQTT library: pip install paho-mqtt.
  2. Inside the handle_command function, after toggling the GPIO pin, add client.publish(f"home/lights/relay_{relay_idx}", not current_state).
  3. Configure Home Assistant to listen to those MQTT topics. This allows your physical IR remote to act as a secondary switch for smart bulbs, keeping the physical state and digital state perfectly synchronized.
Safety Note: If you are switching mains voltage (120V/240V AC) with these relays, ensure your relay module is rated for the specific load type. A 10A resistive rating does not mean it can handle a 10A inductive motor load. Always use a snubber circuit (RC network) across inductive loads to prevent relay contact welding.