My Solution for a Minimal IR receiver decoder without library

Hi all (first post!), I thought I'd share this after searching for a low level (i.e. small = no library calls) solution, finding nothing, and then managing to crack it myself. I wanted a simple IR decoder to control a motorised volume pot by listening for activity from the main TV remote's Vol+/Vol- button. And, I wanted to run it on an ATTiny because these have just caught my interest. The context is a 5.1 sound system small enough to hide behind the TV (70mm gap) made from a couple of class D amps and a 5.1 decoder of which all (or most) seem to lack volume control.

I only completed it yesterday & improvements are of course possible, but the general idea is to wait for IR activity, then time pulses with pulseIn(), discriminate between 0 and 1 from the pulse duration, build up the 32 bit control word, and then analyse this to dig out the command and react to it. The remote's "repeat" function is handled by keeping track of how recent the last IR activity was, rather than trying to decode the repeat code (which is a bit more tricky as the framing is different to an ordinary command).

Acknowledgement to this material which I used to understand the NEC code.

Also note - specific to LG TVs - if using optical out the Magic Remote won't emit IR for Vol+/Vol-. So after I worked out the remote codes using the remote in isolation, I was puzzled as to why nothing worked in front of the TV. The solution is to ensure that LG Sound Sync is turned off (a setting under optical out selection), and use 'Device Connector' to tell the TV that you have a sound bar (the only thing selectable with an optical connection) and use one of the pre-set code tables selected by replying "yes" or "no" to "does this work?" on the TV. Once you've selected a code table on the TV (I chose one of the first few), observe the ATTiny LED and see what IR codes get transmitted (by the TV, not the Magic Remote!) when you press the volume keys on the Magic Remote, and adjust your software to look for those.

Here's the code:

// TV Remote Vol up / Vol down decoder and motor driver
// for motorised volume control using ATTiny88
// and LG Magic Remote
//
// (C) 2025 Alan Robinson G1OJS 

#define IR_PIN 24  // A5 on my board with DrAzzy core
#define LED_PIN 0  // Built in LED
#define MOTORPOS_PIN 10
#define MOTORNEG_PIN 11
// Magic Remote Vol+ / Vol -
#define MRtx_VOL_UP 0xFA    
#define MRtx_VOL_DOWN 0xF8
// 'Device Connector' codes (LG Sound Sync off)
#define TVtx_VOL_UP 0xE8     
#define TVtx_VOL_DOWN 0xE9
#define LEFT 2
#define RIGHT 1
#define OFF 0
unsigned long lastPulseDetected_ms=0;  // to decide if button is held down

void motorDrive(unsigned char state){
  digitalWrite(MOTORPOS_PIN,(state==RIGHT));
  digitalWrite(MOTORNEG_PIN,(state==LEFT));
  digitalWrite(LED_PIN, (state!=OFF));
  // if motor drive is on, pause here until most recent IR activity is > ~ 0.2 seconds
  // (i.e. wait for button release)
  if (state!=OFF) {
    do {
      if (digitalRead(IR_PIN)==LOW) lastPulseDetected_ms=millis();
    }   while ((millis()-lastPulseDetected_ms) < 200);
  }  
}

// This is for debugging and working out codes only.
// Blinks on a regular 'beat' (500ms) with 400ms flash = 1, 100ms flash = 0
// First delay() sets the duration, second delay pads to 500ms
void blinkLED_bits(unsigned long bits) {
  for (int i = 0; i < 8; i++) {
    digitalWrite(LED_PIN, HIGH);
    delay((bits & 128)? 400:100);
    digitalWrite(LED_PIN, LOW);
    delay((bits & 128)? 100:400);
    bits *=2; // used * rather than bit shift to be absolutely sure I'm shifting the right way!
  }
  digitalWrite(LED_PIN, LOW);
  delay(200);
}

// This is the core of the code, waiting for IR activity and then timing pulses to decide
// whether a 0 or 1 was transmitted and packing these bits into the 32 bit control word 
// It uses the NEC Protocol. Note that IR module ouitput is HIGH when IR remote is OFF, 
// and LOW when IR remote is transmitting IR. Code could be made more readable by adding 
// something to make this explicit.
// https://wiki.keyestudio.com/052035_Basic_Starter_V2.0_Kit_for_Arduino#Project_14:_IR_Remote_Control
unsigned long getAddrAndCmdWord(){
  unsigned long cmdWord;
  unsigned char i=0;
  while (digitalRead(IR_PIN)==HIGH) {};
  cmdWord=0;
  do {
    cmdWord >>= 1;
    if(pulseIn(IR_PIN, HIGH, 5000) < 1250) 
      cmdWord |=0x80000000;
  } while (i++ < 32);
  return cmdWord;
}

// helper function to check that the received word is valid
// by checking that the inverted repeat of the command matches the first transmitted command
unsigned char validCmd(unsigned long addrAndCmdWord){
  byte cmd    = (addrAndCmdWord >> 16) & 0xFF;
  byte cmdInv = (addrAndCmdWord >> 24) & 0xFF;
  if ((cmd ^ cmdInv) == 0xFF) {return cmd;} else {return 0;}
}

// the usual stuff to set up pins ...
void setup() {
  pinMode(IR_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  pinMode(MOTORPOS_PIN, OUTPUT);
  pinMode(MOTORNEG_PIN, OUTPUT);  
}

// get the command word, pull out commands if valid, drive the motor.
// Readability again - need to make the "wait until IR is quiet again"
// more explicit here (it's hidden above in motorDrive())
void loop() {
  unsigned long addrAndCmdWord = getAddrAndCmdWord();
  unsigned char cmd = validCmd(addrAndCmdWord);
  if ((cmd == MRtx_VOL_UP) || (cmd == TVtx_VOL_UP) )  { motorDrive(RIGHT); }
  else if ((cmd == MRtx_VOL_DOWN) || (cmd == TVtx_VOL_DOWN) ) { motorDrive(LEFT); }
  else {blinkLED_bits(cmd);}
  motorDrive(OFF);
}


Hardware wise, this is currently running on a £5 ATTiny88 with an L9110H bridge handling the motor current (which is, annoyingly, only a small factor above the 40mA pin limit ...). Next steps for me are code tidying and porting to my first ATTiny85, which are coming in the post today ...

Oh - and of course, monitoring the spare 'gang' on the 6 gang pot to limit motor drive instead of just leaving the coupling screws slightly loose & keeping fingers crossed ...

4 Likes