PIC Philips RC5 receiving

Interrupt and Timer based RC5 receiver

Adding IR (infrared) remote control to your project really improves a project. I use the code here every day when controlling the volume of my hi-fi home cinema amplifier, and also, on my TDA1514/TDA2040 Surround Sound Amplifier and STA540/TDA7439 stereo amplifier.

The latter project probably has the best code. Examples below are from that project. See TDA7439/STA540 Amplifier Part 2 - Software for a code download.

The Philips RC5 protocol is a fairly simple to interpret protocol. It's old and was common with TVs and VCRs from the 90's but now it is less used.

This makes it ideal for using IR to control your home-made equipment in a room full of modern TV and Home Cinema equipment. You can learn about the protocol at SB-Projects.

I started out with a delay based routine to read IR commands, but I prefer an interrupt based routine so that processing IR commands is not blocking the execution of any other code in the PIC.

I found some code online for handling RC5 protocol commands which I modified for the SourceBoost BoostC compiler. It works very well for my main three amplifiers that I've built.

My adjustments below allow for efficient code generation and good reliability even on a 4MHz PIC, remove all delay routines, and add an LED for activity indication. The original is from ArduinoTamil.

The code is quite clever, because it is using the interrupt routine to handle the logic high and low states of the IR receiver (toggling the edge detection in each interval), and also the interrupt routine for the timer ticks.

By counting an interval of both the timer, and the logic changes, and ensuring that they are the same, it can detect a valid command. The interval can also be used to determine where in the protocol it is currently processing (start bits, control bit, address, and command).

Variables

In the header (.h) file, add some variables:

#define IR_PIN (portb.0)
#define IR_LED (portb.1)

// 26 for 208us on 4MHz, 1:8 postscaler
#define IR_PR2_200US 26
// 109 for 872us on 4MHz, 1:8 postscaler
#define IR_PR2_890US 109

// For IR
char intfCounter = 0;
char rc5_Held = 0;
unsigned short rc5_inputData; // input data takes 12 bits
char rc5_bitCount;
char rc5_logicInterval, rc5_logicChange;
enum {
        rc5_idleState,
        rc5_initialWaitState,
        rc5_startBitState,
        rc5_captureBitState
};

char rc5_currentState = rc5_idleState;
char rc5_pinState = 1;

char rc5_flickBit = 0;
char rc5_flickBitOld = 0;
char rc5_address = 0;
char rc5_command = 0;

Of these variables, IR_PR2_200US and IR_PR2_890US may need adjusting. The values of 26 and 109 work well for a 4MHz oscillator.

Initialisation

The next set of code is for initialisation (when the PIC powers on). This should be in an initialise() routine, or in the main() function before it enters the endless loop.

Two peripherals are used here that need initialising correctly: the timer (here Timer2) and the external interrupt pin.

I'm also showing the initialisation of timer 1. This is used for button hold actions - in my example holding the mute button down for a few seconds gives the system a secondary menu.

/***********************************************************************************
  Function called once only to initialise variables and setup the PIC registers
************************************************************************************/
void initialise() {
    trisb = 0x01; // RB0 is an input - but set as you want
    portb = 0x00; // set to off

    option_reg.NOT_RBPU = 1; // Port B pull-ups disabled
    
    // Timer calculator: http://eng-serve.com/pic/pic_timer.html
    // Timer 1 setup - interrupt every 131ms seconds 4MHz
    // This is optional and used only for button hold actions
    // Timer1 Registers Prescaler= 2 - TMR1 Preset = 0 - Freq = 7.63 Hz - Period = 0.131072 seconds
    t1con.T1CKPS1 = 0;   // bits 5-4  Prescaler Rate Select bits
    t1con.T1CKPS0 = 1;   // bit 4
    t1con.T1OSCEN = 0;   // bit 3 Timer1 Oscillator Enable Control bit 1 = off
    t1con.NOT_T1SYNC = 1;// bit 2 Timer1 External Clock Input Synchronization Control bit...1 = Do not synchronize external clock input
    t1con.TMR1CS = 0;    // bit 1 Timer1 Clock Source Select bit...0 = Internal clock (FOSC/4)
    t1con.TMR1ON = 0;    // bit 0 enables timer
    tmr1h = 0;           // preset for timer1 MSB register
    tmr1l = 0;           // preset for timer1 LSB register

    pie1.TMR1IE = 1; // Timer 1 interrupt
    pir1.TMR1IF = 0; // Clear timer 1 interrupt flag bit

    // Timer 2 setup - interrupt every 890us (0.00088800)
    // 4MHz settings
    // Timer2 Registers Prescaler= 1 - TMR2 PostScaler = 8 - PR2 = 110 - Freq = 1136.36 Hz - Period = 0.000880 seconds
    t2con |= 56;       // bits 6-3 Post scaler 1:1 thru 1:16
    t2con.TMR2ON = 0;  // bit 2 turn timer2 off;
    t2con.T2CKPS1 = 0; // bits 1-0  Prescaler Rate Select bits
    t2con.T2CKPS0 = 0;
    pr2 = IR_PR2_890US;// Preload timer2 comparator value - 0.00088800s
    pir1.TMR2IF = 0;   // Clear timer 2 interrupt flag bit
    pie1.TMR2IE = 1;   // Timer 2 interrupt enabled

    // Setup for RB0 Interrupt [IR Sensor]
    intcon.INTE = 1; // RB0 Interrupt (for IR receive)
    option_reg.INTEDG = 0; // RB0 interrupt should occur on falling edge
    intcon.INTF = 0; // Clear RB0 interrupt flag bit
    
    // Interrupt setup
    intcon.PEIE = 1; // Enables all unmasked peripheral interrupts (required for RS232 and I2C)

    // Enable global interrupts
    intcon.GIE = 1;

    // No task at initialisation
    cTask = 0;
}

External interrupt

For this interrupt driven code to work, the IR receiver (such as a TSOP4838), needs to be connected to an external interrupt pin. This is usually just RB0, but some PICs also have external interrupt capability on RB1 and RB2 too. With code modifications, I guess this could also work on the interrupt on change pins RB4 to RB7.

Setting up the interrupt will vary per PIC, but on common PIC16 microcontrollers it is usually controlled by the bits INTE and INTF in the INTCON register, and bit INTEDG in the OPTION register to control the edge direction (rising or falling).

On the PIC18 chip I've used (PIC18F4455), it's bits INT0IE and INT0IF in INTCON, and INTEDG0 in INTCON2 for RB0. For RB1 as an alternative, bits INT1E and INT1F in INTCON3, INTEDG1 in INTCON2, and for RB2 as an alternative, bits INT2E and INT2F in INTCON3, INTEDG2 in INTCON2.

After each interrupt, the interrupt routine will check and clear the INTF bit, and toggle the INTEDG bit, so will need adjusting for your micro.

Timer2

The timer is used instead of blocking delay routines. The timer should be setup to interrupt at around 890µs - that's the half the bit pulse length for the RC5 protocol (which is 1.78ms in total).

A great resource for calculating the setup values for a timer is here - http://eng-serve.com/pic/pic_timer.html. By specifying your clock speed, it gives the values needed to setup the timer to interrupt at a period needed.

The key is to get the Timer2 Prescaler, Postscaler and PR2 Register at convenient values for delays of both 890µs and 200µs.

On a PIC that is running at 4MHz for example, a 1:1 Prescaler, 1:8 Postscaler gives a period of 8µs - that's FOSc/4 * 8 (8 being the postscaler). PR2 defaults to 255, but by preloading it with a value each time the timer overflows, we can make it count a specific amount of time before an interrupt is generated.

Loading 111 into PR2 means that 111 counts of 8µs periods will happen before an interrupt occurs - that's 888µs, extremely close to the 890µs needed. In reality, because 4MHz is quite slow (but power efficient), the overhead of processing instructions, the timer needs to interrupt a little sooner and I find 872µs works better.

An initial delay is also required in order to put the subsequent 890µs interrupts at a place where they are right in the middle of the bit being received. By making timer2 count an initial delay of around 200µs, after the first edge of the command is received, that enabled the subsequent delays to correctly read the state of the IR input port.

Here's a little table with settings that I've used so far:

Speed Timer Prescaler Postscaler First interrupt interval µs First interrupt Preload Subsequent interrupt interval µs Subsequent interrupt Preload
4MHz Timer2 1:1
T2CON T2CKPS1:T2CKPS0 = 00
1:8
T2CON TOUTPS3:TOUTPS0 = 0111
208 PR2 = 26 872 PR2 = 109
4MHz Timer0 1:8
OPTION PS2:PS0 = 010
N/A 208 TMR0 = 230 872 TMR0 = 146
10MHz Timer2 1:4
T2CON T2CKPS1:T2CKPS0 = 01
1:6
T2CON TOUTPS3:TOUTPS0 = 0101
201.6 PR2 = 21 883.2 PR2 = 92
16MHz Timer2 1:4
T2CON T2CKPS1:T2CKPS0 = 01
1:6
T2CON TOUTPS3:TOUTPS0 = 0101
204 PR2 = 34 888 PR2 = 148
24MHz Timer2 1:4
T2CON T2CKPS1:T2CKPS0 = 01
1:6
T2CON TOUTPS3:TOUTPS0 = 0101
200 PR2 = 50 888 PR2 = 222

Note: I've included a Timer 0 row for flexibility in case your timer 2 is already used for another purpose, but other code changes would be needed in order for it to work with timer 0 instead.

Interrupt routine

This is where the clever part is performed. You shouldn't need adjustments here other than the timer is it's changed, or the external interrupt due to differences between the PIC16 and PIC18. Below is the PIC16 version, but substitute option_reg.INTEDG with intcon2.INTEDG0 and intcon.INTF with intcon.INT0IF should be enough.

This interrupt routine controls a state machine. Compared to the original, I added an additional state of rc5_initialWaitState for that 200µs initial delay.

The states are:

  1. rc5_idleState - nothing happening
  2. rc5_initialWaitState - happens when previous state was rc5_idleState and an edge was detected
  3. rc5_startBitState - the first state after the 200µs initial wait
  4. rc5_captureBitState - capturing all the bits after the start bit. 12 bits expected.

The code counts the timer interval changes and the logic changes of the external interrupt, and if they are expected, will then read the data appropriately. Any errors set an iReset flag which will then set the state back to rc5_idleState and turn off the timer and set the external interrupt to interrupt on the falling edge again.

/***********************************************************************************
  Interrupt handler
************************************************************************************/
void interrupt(void) {
    // external interrupt on RB0 - IR sensor
    if (intcon.INTF) {
        option_reg.INTEDG = intfCounter.0;
        intfCounter++;
        rc5_logicChange++;
        if (rc5_currentState == rc5_idleState) {
            // If the state was idle, start the timer
            rc5_logicInterval = 0;
            rc5_logicChange = 0;
            // Timer 2 should run for about 200us at first
            tmr2 = 0;
            // 4MHz settings
            pr2 = IR_PR2_200US;
            pir1.TMR2IF = 0; // Clear interrupt flag
            t2con.TMR2ON = 1; // Timer 2 is on
            rc5_currentState = rc5_initialWaitState;
        }
        intcon.INTF = 0; //clear interrupt flag.
    }
    // Interrupt on timer2 - IR code https://tamilarduino.blogspot.com/2014/06/ir-remote-philips-rc5-decoding-using.html
    if(pir1.TMR2IF) {
        rc5_pinState = IR_PIN;
        if (rc5_currentState != rc5_initialWaitState) {
            rc5_logicInterval++;
            IR_LED = rc5_logicInterval.0; // Flick IR LED
        }
        char iReset = 0;
        // Switch statement to process IR depending on where/state of the command timer currently expects to be
        switch (rc5_currentState){
            // If in initial wait state - timer completed the first 200us, switch to the normal 890us
            case rc5_initialWaitState:
                // Timer 2 interrupt every 890us
                tmr2 = 0;
                // 111 for exactly 888us
                pr2 = IR_PR2_890US; // Preload timer2 comparator value - 888us (0.000888s)
                // Switch to start bit state
                rc5_currentState = rc5_startBitState;
                break;
            // If in start bit state - check for (second) start bit, Logic on RB0 must change in 890us or considers as a fault signal.
            case rc5_startBitState:
                if ((rc5_logicInterval == 1) && (rc5_logicChange == 1)) {
                    // Valid start bits were found
                    rc5_logicInterval = 0;
                    rc5_logicChange = 0;
                    rc5_bitCount = 0;
                    rc5_inputData = 0;
                    rc5_currentState = rc5_captureBitState; // Switch to capturing state
                } else {
                    iReset = 1;
                }
                break;
            // If in capture bit state - sample RB0 logic every 1780us (rc5_logicInterval = 2)
            // Data is only valid if the logic on RB0 changed
            // Data is stored in rc5_command and rc5_address
            case rc5_captureBitState:
                // Logic interval must be 2 - 1780us
                if(rc5_logicInterval == 2) {
                    // Logic change must occur 2 times or less, otherwise it is invalid
                    if(rc5_logicChange <= 2) {
                        rc5_logicInterval = 0;
                        rc5_logicChange = 0;
                        // If the number of bits received is less than 12, shift the new bit into the inputData
                        if(rc5_bitCount < 12) {
                            rc5_bitCount++;
                            rc5_inputData <<= 1; // Shift recorded bits to the left
                            if(rc5_pinState == 1) {
                                rc5_inputData.0 = 1; // Add the new bit in
                            }
                        } else {
                            // All 12 bits received
                            rc5_command = rc5_inputData & 0x3F; // 00111111 - command is the last 6 bits
                            rc5_inputData >>= 6; // Shift 6 bits right, clearing command
                            rc5_address = rc5_inputData & 0x1F; // 00011111 - address is now the last 5 bits
                            rc5_inputData >>= 5; // Shift 5 bits right, clearing address
                            // Last bit is the flick bit
                            rc5_flickBit = rc5_inputData;
                            
                            // Flag this task to the task array - IR command will be processed in the main loop
                            cTask.TASK_INT_EXT0 = 1;

                            // Command finished - reset status
                            iReset = 1;
                        }
                    } else {
                        // Not valid - reset status
                        iReset = 1;
                    }
                }
                break;
            default: 
                iReset = 1;
        }
        
        // Reset status if not valid
        if (iReset) {
            // Not valid - reset status
            rc5_currentState = rc5_idleState;
            t2con.TMR2ON = 0; // Disable Timer 2
            option_reg.INTEDG = 0; // Interrupt on falling edge
            IR_LED = 0; // switch off IR LED
        }
        pir1.TMR2IF = 0; // Clear interrupt flag
    }

    // timer 1 interrupt - process mute button released
    // optional code
    if (pir1.TMR1IF) {
        // timer 1 will interrupt every 131ms with a 1:2 prescaler at 4MHz
        // This is just longer than an RC5 message
        
        // If held more than 14 times (114ms * 14 is just over 1.5 seconds), enter function mode
        if (iMuteHeld < 15) {
            // flag for muting
            cTask.TASK_TIMER1_MUTE = 1;
        }
        
        pir1.TMR1IF = 0; // Clear interrupt flag
    }
}

If the interrupt routine successfully processes a command to its end, then a flag named TASK_INT_EXT0 is set in a cTask byte. A separate routine can then read the received values in rc5_address, rc5_command and rc5_flickBit and process them.

Task scheduler

Code in the interrupt routine should be kept minimal and processing a received command should be done outside the interrupt routine.

I suggest a mini task scheduler which sits inside the infinite loop in the main() routine. This scheduler will check for flags in a global byte and call the relevant sub-routine for processing them if they are set. There are eight bits in a byte, so this scheduler will allow eight separate tasks, of which 'TASK_INT_EXT0' is one of them, but to allow for more than eight, another byte can be created.

    while (1) {
        // Task scheduler
        // If there are tasks to be performed, find out the
        // most recent task from the array and execute it
        while (cTask > 0) {
            if (cTask.TASK_INT_EXT0) {
                rc5Process(); // IR sensor received a signal
                cTask.TASK_INT_EXT0 = 0;
            } 
            // Timer 1 functions if you want button hold actions
            if (cTask.TASK_TIMER1_MUTE) {
                // Mute and update display
                doMute(); // example
                showVolAndInput(); // example
                // turn off the timer
                timer1Reset();
                cTask.TASK_TIMER1_MUTE = 0;
            }
            if (cTask.TASK_TIMER1_FUNC) {
                // enter function mode
                iFunctionMode = 1; // example
                // Show first function
                functionDisplay(); // example
                // turn off the timer
                timer1Reset();
                cTask.TASK_TIMER1_FUNC = 0;
            }
        }
    }

The rc5Process() can be custom, but here is an example from my TDA7439 amplifier, which processes buttons for power, volume up/down, input (channel) up/down and mute. It also includes starting a second timer1 for determining how long the mute button is held to enter a special 'function' mode.

/***********************************************************************************
  Read and process remote control RC5 commands
************************************************************************************/
void rc5Process() {
    IR_LED = 0; // switch off IR LED
    if (rc5_address == 0) { // Addresses above zero are not for this device
        // Process commands
        if (iPower) { // Don't process the following if power is off
            // Get current volume level
            switch (rc5_command) {
                // For each command, cause the correct action 
                case 13: // Mute (13 / 0x0D / D)
                    // example of button hold alternative action, using timer 1
                    if (rc5_flickBitOld != rc5_flickBit) { // Prevent repeated muting when holding the button
                        if (iFunctionMode == 0) {
                            // reset timer
                            timer1Reset();
                            // flag for muting
                            iMuteTimer = 1;
                            // start call timer
                            t1con.TMR1ON = 1;
                        } else {
                            // exit function mode
                            iFunctionMode = 0;
                            showVolAndInput();
                            // turn off the timer
                            timer1Reset();
                        }
                    } else {
                        // Button held - this should reset the iMuteTimer before the timer1 picks it up, therefore avoiding the mute function
                        iMuteTimer = 0;
                        // If timer 1 has counted over 11 times (131ms * 12 is just over 1.5 seconds), enter function mode
                        if (iTimer1Count > 11) {
                            // enter function mode
                            iFunctionMode = 1;
                            // Show first function
                            functionDisplay();
                            // turn off the timer, before it counts to onTimer1() / reset display 
                            timer1Reset();
                        }
                    }
                    break;
                case 16: // Volume up (16 / 0x10 / E)
                    if (iFunctionMode == 0) {
                        // Increase level (decrease attuation)
                        iVolume--;
                        if (iVolume > 56)
                            iVolume = 0;
                        tda7439SetVolume(); // example
                        showVolAndInput(); // example
                    } else {
                        functionRaise(); // example alt action
                    }
                    break;
                case 17: // Volume down (17 / 0x11 / F)
                    if (iFunctionMode == 0) {
                        // Decrease level (increase attuation)
                        iVolume++;
                        if (iVolume > 56)
                            iVolume = 56;
                        tda7439SetVolume(); // example
                        showVolAndInput(); // example
                    } else {
                        functionLower(); // example alt action
                    }
                    break;
                case 32: // Input right (32 / 0x20 / V)
                    if (rc5_flickBitOld != rc5_flickBit) { // Prevent repeated input changing when holding the button
                        if (iFunctionMode == 0) {
                            doInputUp(); // example
                            showVolAndInput(); // example
                        } else {
                            functionUp(); // example alt action
                        }
                    }
                    break;
                case 33: // Input left (33 / 0x21 / U)
                    if (rc5_flickBitOld != rc5_flickBit) { // Prevent repeated input changing when holding the button
                        if (iFunctionMode == 0) {
                            doInputDown(); // example
                            showVolAndInput(); // example
                        } else {
                            functionDown(); // example alt action
                        }
                    }
                    break;
            }
        }
        // Process power button regardless of power state
        if (rc5_command == 12) { // Power (12 / 0x0C / A)
            if (rc5_flickBitOld != rc5_flickBit) { // Prevent repeated power when holding the button
                // exit function mode
                iFunctionMode = 0;
                iPowerExternal = 0;
                // power up or down
                doPower(); // example
            }
        }
    }
}

This method checks the address. If that's not zero, then nothing more happens, and the method exits. Otherwise, the command received via IR is processed instead.

The following commands are processed if the power is on only:

  • Command 13 / 0x0D: Mute - do the mute or unmute process
  • Command 16 / 0x10: Volume up - call a method that will rotor the motor clockwise
  • Command 17 / 0x11: Volume down - call a method that will rotor the motor anti-clockwise
  • Command 32 / 0x20: Input right (channel up) - increment the selected input, then activate the appropriate relays
  • Command 33 / 0x21: Input left (channel down) - decrement the selected input, then activate the appropriate relays

Command 12 / 0x0C for Power is always allowed. When this command is received, the power on and off sequence is executed.

Certain commands are also prevented from repeating if the button on the remote is pressed and held down. These are Power, Mute and changing the Input up/down - we don't want these cycling states rapidly. Volume however increases or decreases continuously whilst the user holds the button down - we don't want the user to have to rapidly mash the volume buttons to adjust the volume quickly!

RC5 protocol provide a flick or toggle bit for this, and by comparing the previous value to the newly received value, if it remains the same, we know the button is being held down.

Transmission

I've never coded a transmitter for the remote yet. That's a different task that would involve PWM modulating an IR LED at 36kHz with 890µs delays between each bit.

Whilst I've no doubt the software to do it wouldn't be too difficult, and there are plenty of examples online, for me, building an attractive looking remote that's small and has great battery life would be a challenge. Many universal remotes can be programmed to certain brands though and picking a Philips TV code from the top of the table would likely transmit the codes I'm handling above. The One 4 All Zapper URC6210 is a great example, though it's discontinued.

Alternatively, you could program a transmitter, then program a universal learning remote to learn each command.

Timer 1

Timer 1 is used as a secondary timer to count how long a button has been held for. I use this to enter a secondary menu on my TDA7439/STA540 amp so that a secondary menu can be entered for adjustments to bass, treble, balance.

Timer 1 code is listed above, but exclude it if you do not need this feature.

The timer1Reset() function is below, for reference, if you do use it:

void timer1Reset() {
    // switch off timer, and reset counters
    t1con.TMR1ON = 0;
    tmr1h = 0;
    tmr1l = 0;
}

Conclusion

On its own, this code will consume around 350 words of ROM on a PIC16 and can run successfully on a 4MHz oscillator (including internal oscillators). You might get it smaller with a delay based routine, but being interrupt driven allows processing of other commands to still occur in the gaps between each bit.

I never spent enough time to get it working, but an ability for the PIC to sleep or enter slower idle clock speeds would also be possible, however, changes would be needed in the code to handle the waking up delay and prevent sleeping until the command is processed or reset.

I've used this code on three different projects so far with success though, and feel it deserves its own writeup!

References and more reading:
Various PIC datasheets
Tamil Arduino - IR RC5 decoding code using interrupts
SB-Projects - Philips RC-5 Protocol
Dring Engineering Services - IC Timer Calculator and Source Code Generator
Source Boost Technologies