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:
- rc5_idleState - nothing happening
- rc5_initialWaitState - happens when previous state was rc5_idleState and an edge was detected
- rc5_startBitState - the first state after the 200µs initial wait
- 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