Community project

IR NFC Macro Lab

ESP32
Photo of IR NFC Macro Lab
Generated with AI

Hema 2

Published October 25, 2026

The IR NFC Macro Lab is a portable ESP32-based device that captures and replays infrared signals, reads NFC tags, and stores macro commands on a microSD card. It combines an OLED display, NFC reader, IR receiver and transmitter, and button controls into a compact lab tool for reverse-engineering and automating IR devices and NFC interactions.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for building the device from scratch. Readers will also receive the full Arduino firmware with USB keyboard emulation, IR envelope capture at 38 kHz, NFC Type 2/4 NDEF support, and macro playback capabilities—all with graceful degradation if any peripheral is missing.

Wiring diagram

Gather all the parts

QtyComponent
1

SSD1306 OLED

0.96 inch 128x64 OLED display with I2C interface

1

PN532 NFC Module (I2C Mode)

NXP PN532-based NFC/RFID reader-writer module supporting ISO 14443A/B, MIFARE, NTAG213, NTAG215, and NTAG216 tags. Supports three interface modes (I2C, SPI, HSU/UART) selected by on-board DIP switches. I2C mode: both DIP switches set to ON (SW1=ON, SW2=ON). I2C address 0x24. Operates at 3.3 V, compatible directly with ESP32 without level shifting. On-board pull-up resistors provided for I2C lines.

1

Adafruit MicroSD SPI/SDIO Breakout Board (3V Only)

MicroSD card breakout for adding file storage to a microcontroller project, wired over SPI (any MCU) or native SDIO (faster, needs SDIO hardware/firmware support like SAMD51). Board has no onboard regulator or level shifter, so it runs on 3.3V logic and power only — do not connect directly to a 5V system.

1

TSOP38238 IR Receiver

Vishay TSOP38238 miniature 38 kHz IR demodulator module. Integrates photodetector, AGC, bandpass filter, and demodulator. Output is active-low (open-collector style), directly compatible with 3.3 V ESP32 GPIO input. Supply voltage 2.5 V–5.5 V; datasheet recommends 100 Ω series resistor on VS pin plus 4.7 µF decoupling capacitor to suppress power-supply noise. Three units provide left / center / right IR beacon homing by comparing received-signal timing on three separate GPIO inputs.

1

Push Button

Momentary push button switch

1

Push Button

Momentary push button switch

1

Push Button

Momentary push button switch

1

Push Button

Momentary push button switch

1

Vishay TSAL6200 infrared LED

940 nm emitter; short cathode lead at flat side.

1

onsemi PN2222A transistor

TO-92 E-B-C; confirm exact PN2222A, not P2N2222A.

1

100 ohm 0.25 W resistor

Limits IR LED current to about 20 mA.

1

1 kohm resistor

Limits transistor base current.

1

10 kohm resistor

Holds IR transmitter off during reset.

1

100 ohm receiver supply resistor

Filters receiver supply noise.

1

4.7 uF capacitor rated 6.3 V or higher

Receiver decoupling; observe polarity.

Assemble it in 6 steps

1. Connect the screen and NFC reader

With USB unplugged, connect OLED and PN532 VCC to 3V3 and GND to GND. Connect both SDA pins to GPIO8 (data) and both SCL pins to GPIO9 (clock). Connect PN532 IRQ to GPIO17. Leave RSTO disconnected. On the Elechouse V3 set switch 1 ON and switch 2 OFF for I2C, following the maker's manual rather than the catalog's switch note.

  • Use 3.3 V power and logic only.

2. Connect the storage module

Use the 3 V-only breakout. Connect 3V to 3V3, GND to GND, CLK to GPIO12 (clock), MOSI/SI to GPIO11 (data to card), MISO/SO to GPIO13 (data from card), and CS to GPIO10 (select). Insert a FAT32-formatted MicroSD card. Other socket pins remain disconnected.

3. Connect four buttons

Connect each button between GND and its input. UP uses GPIO4, DOWN GPIO5, OK GPIO6, and BACK GPIO7. Use opposite switched sides, not two internally joined legs.

4. Connect the infrared receiver

Connect OUT to GPIO14 and GND to GND. Feed VS from 3V3 through the 100 ohm supply resistor. Place the 4.7 uF capacitor between VS (+) and GND (-), close to the receiver. Check the Vishay receiver datasheet for lead order before insertion.

5. Build the infrared transmitter

Connect 3V3 through the LED's 100 ohm resistor to the TSAL6200 anode. Connect the cathode to PN2222A collector. Connect emitter to GND. Connect GPIO15 through 1 kohm to base, and place 10 kohm between base and GND. This is a conservative short-range transmitter, not a high-power blaster.

  • Confirm the transistor is PN2222A with E-B-C leads; P2N2222A has a different order. Never omit the LED resistor.

6. Check power and USB

Check every power wire before plugging in USB. Use the ESP32-S3 native USB connector for keyboard output, not its USB-to-serial connector. A USB power bank can power standalone IR and NFC use; no bare battery is connected. Only connect HID to a computer you own or have permission to operate.

  • Use short wires and a common ground. BACK cancels operations. Macros require selecting Run and a second OK confirmation.

Review all connections

1. Connections between "oled" and "ESP32"

FunctionoledESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 8
i2cSCLGPIO 9

2. Connections between "nfc" and "ESP32"

FunctionnfcESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 8
i2cSCLGPIO 9
digitalIRQGPIO 17

3. Connections between "sd" and "ESP32"

FunctionsdESP32
power3V3V3
groundGNDGND
spiCLKGPIO 12
spiMISOGPIO 13
spiMOSIGPIO 11
spiCSGPIO 10

4. Connections between "rx" and "ESP32"

FunctionrxESP32
powerVS → 100 ohm receiver supply resistor 2EXT
groundGNDGND
digitalOUTGPIO 14

5. Connections between "rfilter" and "ESP32"

FunctionrfilterESP32
power13V3

6. Connections between "cfilter" and "ESP32"

FunctioncfilterESP32
power+ → TSOP38238 IR Receiver VSEXT
ground-GND

7. Connections between "tx" and "ESP32"

FunctiontxESP32
powerA → 100 ohm 0.25 W resistor 2EXT
groundK → onsemi PN2222A transistor CEXT

8. Connections between "rled" and "ESP32"

FunctionrledESP32
power13V3

9. Connections between "q1" and "ESP32"

Functionq1ESP32
groundEGND
digitalB → 1 kohm resistor 2EXT

10. Connections between "rbase" and "ESP32"

FunctionrbaseESP32
digital1GPIO 15

11. Connections between "rpulldown" and "ESP32"

FunctionrpulldownESP32
digital1 → onsemi PN2222A transistor BEXT
ground2GND

12. Connections between "up" and "ESP32"

FunctionupESP32
digitalSIGNALGPIO 4
groundGNDGND

13. Connections between "down" and "ESP32"

FunctiondownESP32
digitalSIGNALGPIO 5
groundGNDGND

14. Connections between "ok" and "ESP32"

FunctionokESP32
digitalSIGNALGPIO 6
groundGNDGND

15. Connections between "back" and "ESP32"

FunctionbackESP32
digitalSIGNALGPIO 7
groundGNDGND

Deploy the firmware

/* ESP32-S3 portable lab. Native USB OTG, US keyboard layout.
   IR: demodulated envelope only; fixed 38 kHz replay, not carrier measurement.
   NFC: public Type 2/Type 4 NDEF only; no encrypted-card cloning.
   Target UID is always 08:xx:xx:xx; this is not arbitrary UID emulation.
   Limits: 512 IR intervals, 512 NDEF bytes, 8192 file bytes, 160 macro chars/line.
   Missing peripherals disable only their own operations. BACK cancels operations.
   Build: Arduino ESP32-S3, USB_MODE=0, USB_CDC_ON_BOOT=1.
   Libraries: SSD1306 2.5.17, GFX 1.11.11, PN532 1.3.4,
   IRremoteESP8266 2.9.0. All other headers come from Arduino-ESP32. */
#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_PN532.h>
#include <IRremoteESP8266.h>
#include <IRrecv.h>
#include <IRsend.h>
#include <USB.h>
#include <USBHIDKeyboard.h>

constexpr int SDA_PIN=8, SCL_PIN=9, SD_CS=10, MOSI_PIN=11, SCK_PIN=12,
              MISO_PIN=13, RX_PIN=14, TX_PIN=15, NFC_IRQ=17;
constexpr int BUTTONS[]={4,5,6,7}; // UP, DOWN, OK, BACK, active low
constexpr size_t MAX_IR=512, MAX_NDEF=512, MAX_FILE=8192;
Adafruit_SSD1306 oled(128,64,&Wire,-1);
Adafruit_PN532 pn(NFC_IRQ,255,&Wire); // no reset wire; RSTO is not driven
IRrecv receiver(RX_PIN,MAX_IR+1,30,true);
IRsend emitter(TX_PIN);
USBHIDKeyboard keyboard;
bool displayOK=false, sdOK=false, nfcOK=false;
uint16_t pulses[MAX_IR], pulseCount=0;
uint8_t ndef[MAX_NDEF]={0xD1,1,12,'T',2,'e','n','L','a','b',' ','r','e','a','d','y'};
uint16_t ndefLen=16;
uint8_t uid[10]={0x08,0x12,0x34,0x56}, uidLen=4;
uint8_t targetTail[3]={0x12,0x34,0x56};
String statusText="Ready", irInfo="No capture", lastScreen;
int selected=0;
const char* menu[]={"IR capture","IR replay","Save /lab.ir","Load /lab.ir",
 "NFC read","NFC emulate","Save /lab.nfc","Load /lab.nfc","Run /macro.txt"};

bool cancelled(){return digitalRead(BUTTONS[3])==LOW;}
void show(const String &text){
  if(text==lastScreen) return;
  lastScreen=text;
  if(!displayOK) return;
  oled.clearDisplay(); oled.setCursor(0,0); oled.print(text); oled.display();
}
void note(const String &s){statusText=s;}
bool waitAbort(uint32_t ms){
  uint32_t start=millis();
  do {if(cancelled()) return false; delay(1);} while(millis()-start<ms);
  return true;
}
String hexBytes(const uint8_t* p,size_t n){
  const char h[]="0123456789ABCDEF"; String s; s.reserve(n*2);
  for(size_t i=0;i<n;i++){s+=h[p[i]>>4];s+=h[p[i]&15];} return s;
}
int nibble(char c){if(c>='0'&&c<='9')return c-'0'; if(c>='A'&&c<='F')return c-'A'+10;
 if(c>='a'&&c<='f')return c-'a'+10; return -1;}
bool unhex(const String &s,uint8_t* out,size_t cap,size_t &n){
  if((s.length()&1)||s.length()/2>cap)return false; n=s.length()/2;
  for(size_t i=0;i<n;i++){int a=nibble(s[2*i]),b=nibble(s[2*i+1]);
    if(a<0||b<0)return false; out[i]=(a<<4)|b;} return true;
}
bool number(const String &s,uint32_t max,uint32_t &v){
  if(!s.length())return false; v=0;
  for(size_t i=0;i<s.length();i++){if(s[i]<'0'||s[i]>'9')return false;
    uint32_t d=s[i]-'0'; if(d>max||v>(max-d)/10)return false; v=v*10+d;} return true;
}
bool readText(const char* path,String &s){
  if(!sdOK)return false; File f=SD.open(path,FILE_READ);
  if(!f||f.isDirectory()||f.size()>MAX_FILE){if(f)f.close(); return false;}
  s=""; s.reserve(f.size());
  while(f.available()){int c=f.read(); if(c==0||s.length()>=MAX_FILE){f.close();return false;}
    s+=(char)c;} f.close(); return true;
}
bool writeText(const char* path,const String &s){
  if(!sdOK||s.length()>MAX_FILE)return false;
  // Fixed paths only; temporary file keeps the old file intact during writing.
  SD.remove("/lab.tmp"); File f=SD.open("/lab.tmp",FILE_WRITE);
  if(!f)return false; size_t count=f.print(s); f.flush(); f.close();
  if(count!=s.length()){SD.remove("/lab.tmp");return false;}
  SD.remove(path); return SD.rename("/lab.tmp",path); // FAT rename is not power-loss atomic.
}
bool lineAt(const String &s,size_t &pos,String &line,size_t maxLen){
  if(pos>=s.length())return false; int end=s.indexOf('\n',pos);
  if(end<0)end=s.length(); line=s.substring(pos,end);pos=end+1;
  if(line.endsWith("\r"))line.remove(line.length()-1);
  return line.length()<=maxLen;
}
bool ndefValid(const uint8_t *p,size_t n){
  if(!n)return true; size_t i=0; bool first=true;
  while(i<n){uint8_t h=p[i++]; if((bool(h&0x80)!=first)||(h&0x20)||(h&7)==7)return false;
    if(i>=n)return false; size_t type=p[i++]; uint32_t payload=0;
    if(h&0x10){if(i>=n)return false; payload=p[i++];}
    else {if(n-i<4)return false; for(int j=0;j<4;j++)payload=(payload<<8)|p[i++];}
    size_t id=0;if(h&8){if(i>=n)return false;id=p[i++];}
    if(type+id>n-i)return false; i+=type+id; if(payload>n-i)return false;i+=payload;
    if(h&0x40)return i==n;first=false;
  }return false;
}
void saveIR(){
  if(!pulseCount){note("No IR data");return;}
  String s="LABIR1\nFREQ 38000\nCOUNT "+String(pulseCount)+"\n";
  for(int i=0;i<pulseCount;i++)s+=String(pulses[i])+"\n";
  note(writeText("/lab.ir",s)?"IR saved":"IR save failed");
}
void loadIR(){
  String s,l;size_t pos=0; uint32_t count,v; uint16_t temp[MAX_IR];
  bool ok=readText("/lab.ir",s)&&lineAt(s,pos,l,40)&&l=="LABIR1";
  ok=ok&&lineAt(s,pos,l,40)&&l=="FREQ 38000";
  ok=ok&&lineAt(s,pos,l,40)&&l.startsWith("COUNT ")&&number(l.substring(6),MAX_IR,count)&&count>0;
  uint32_t total=0;
  if(ok)for(uint32_t i=0;i<count;i++){
    if(!lineAt(s,pos,l,12)||!number(l,60000,v)||v<50){ok=false;break;}
    temp[i]=v;total+=v;if(total>2000000){ok=false;break;}}
  if(ok&&pos<s.length())ok=false;
  if(ok){memcpy(pulses,temp,count*2);pulseCount=count;irInfo="Loaded envelope";}
  note(ok?"IR loaded":"Invalid .ir file");
}
void saveNFC(){
  String s="LABNFC1\nUID "+hexBytes(uid,uidLen)+"\nTARGET 08"+hexBytes(targetTail,3)+
    "\nNDEF "+hexBytes(ndef,ndefLen)+"\n";
  note(writeText("/lab.nfc",s)?"NFC saved":"NFC save failed");
}
void loadNFC(){
  String s,l;size_t pos=0,n=0,u=0,t=0; uint8_t nd[MAX_NDEF],id[10],tar[4];
  bool ok=readText("/lab.nfc",s)&&lineAt(s,pos,l,40)&&l=="LABNFC1";
  ok=ok&&lineAt(s,pos,l,40)&&l.startsWith("UID ")&&unhex(l.substring(4),id,10,u)&&(u==4||u==7||u==10);
  ok=ok&&lineAt(s,pos,l,40)&&l.startsWith("TARGET ")&&unhex(l.substring(7),tar,4,t)&&t==4&&tar[0]==8;
  ok=ok&&lineAt(s,pos,l,1030)&&l.startsWith("NDEF ")&&unhex(l.substring(5),nd,MAX_NDEF,n)&&ndefValid(nd,n);
  if(ok&&pos<s.length())ok=false;
  if(ok){memcpy(uid,id,u);uidLen=u;memcpy(targetTail,tar+1,3);memcpy(ndef,nd,n);ndefLen=n;}
  note(ok?"NFC loaded":"Invalid .nfc file");
}

// PN532 normal frames only, bounded to the ESP32 Wire buffer. No unsafe target helper APIs.
void pnAbort(){const uint8_t ack[]={0,0,255,0,255,0};
 Wire.beginTransmission(0x24);Wire.write(ack,sizeof(ack));Wire.endTransmission();delay(5);}
bool pnReady(uint32_t timeout){uint32_t start=millis();
 do{if(cancelled())return false; if(Wire.requestFrom((uint8_t)0x24,(size_t)1)==1&&Wire.read()==1)return true;
 delay(2);}while(millis()-start<timeout);return false;}
bool pnCommand(uint8_t cmd,const uint8_t *data,size_t n,uint8_t *out,size_t cap,size_t &got,uint32_t timeout=1000){
 got=0;if(n>230)return false;uint8_t frame[240]={0,0,255};uint8_t len=n+2;
 frame[3]=len;frame[4]=-len;frame[5]=0xD4;frame[6]=cmd;uint8_t sum=0xD4+cmd;
 for(size_t i=0;i<n;i++){frame[7+i]=data[i];sum+=data[i];}frame[7+n]=-sum;frame[8+n]=0;
 Wire.beginTransmission(0x24);Wire.write(frame,n+9);
 if(Wire.endTransmission()!=0)return false;
 if(!pnReady(200)){pnAbort();return false;}
 uint8_t ack[7];if(Wire.requestFrom((uint8_t)0x24,(size_t)7)!=7){pnAbort();return false;}
 for(auto &b:ack)b=Wire.read();
 const uint8_t expected[]={1,0,0,255,0,255,0};
 if(memcmp(ack,expected,7)){pnAbort();return false;}
 if(!pnReady(timeout)){pnAbort();return false;}
 uint8_t r[255];size_t read=Wire.requestFrom((uint8_t)0x24,(size_t)255);
 for(size_t i=0;i<read;i++)r[i]=Wire.read();
 if(read<10||r[0]!=1||r[1]!=0||r[2]!=0||r[3]!=255)return false;
 size_t length=r[4];if(length<2||length+8>read||uint8_t(r[4]+r[5])!=0||r[6]!=0xD5||r[7]!=uint8_t(cmd+1))return false;
 sum=0;for(size_t i=6;i<=6+length;i++)sum+=r[i];
 if(sum||r[7+length]!=0||length-2>cap)return false;
 got=length-2;memcpy(out,r+8,got);return true;
}
bool exchange(const uint8_t *p,size_t n,uint8_t *out,size_t &len){
 uint8_t data[232],r[240];size_t got=0;if(n>220)return false;data[0]=1;memcpy(data+1,p,n);
 if(!pnCommand(0x40,data,n+1,r,sizeof(r),got)||got<1||r[0]!=0||got-1>len)return false;
 len=got-1;memcpy(out,r+1,len);return true;
}
bool apdu(const uint8_t *p,size_t n,uint8_t *out,size_t &len){
 if(!exchange(p,n,out,len)||len<2||out[len-2]!=0x90||out[len-1]!=0)return false;len-=2;return true;
}
bool selectFile(uint16_t id){uint8_t p[]={0,0xA4,0,0x0C,2,uint8_t(id>>8),uint8_t(id)},r[32];size_t n=sizeof(r);return apdu(p,sizeof(p),r,n);}
bool readBinary(uint16_t off,uint8_t count,uint8_t *out,size_t &len){
 uint8_t p[]={0,0xB0,uint8_t(off>>8),uint8_t(off),count};return apdu(p,sizeof(p),out,len);
}
bool readType4(uint8_t *out,uint16_t &len){
 uint8_t aid[]={0,0xA4,4,0,7,0xD2,0x76,0,0,0x85,1,1,0};uint8_t r[96];size_t n=sizeof(r);
 if(!apdu(aid,sizeof(aid),r,n)||!selectFile(0xE103))return false;
 n=sizeof(r);if(!readBinary(0,15,r,n)||n!=15||r[7]!=4||r[8]!=6||r[13]!=0)return false;
 uint16_t file=(r[9]<<8)|r[10],max=(r[11]<<8)|r[12];
 if(!selectFile(file))return false;n=sizeof(r);if(!readBinary(0,2,r,n)||n!=2)return false;
 len=(r[0]<<8)|r[1];if(len>MAX_NDEF||max<2||len>max-2)return false;
 for(uint16_t off=0;off<len;){if(cancelled())return false;
   uint8_t chunk=min((uint16_t)48,uint16_t(len-off));n=sizeof(r);
   if(!readBinary(off+2,chunk,r,n)||n!=chunk)return false;memcpy(out+off,r,n);off+=n;}
 return ndefValid(out,len);
}
bool readType2(uint8_t *out,uint16_t &len){
 uint8_t p[]={0x30,3},r[32];size_t n=sizeof(r);
 if(!exchange(p,2,r,n)||n!=16||r[0]!=0xE1||(r[1]>>4)!=1)return false;
 size_t cap=min(size_t(r[2])*8,size_t(888));uint8_t memory[888];size_t used=0;
 for(uint16_t page=4;used<cap;page+=4){if(cancelled())return false;p[1]=page;n=sizeof(r);
   if(!exchange(p,2,r,n)||n!=16)return false;size_t take=min(size_t(16),cap-used);memcpy(memory+used,r,take);used+=take;}
 size_t i=0;while(i<used){uint8_t tag=memory[i++];if(tag==0)continue;if(tag==0xFE)break;
   if(i>=used)return false;size_t l=memory[i++];if(l==255){if(used-i<2)return false;l=(memory[i]<<8)|memory[i+1];i+=2;}
   if(l>used-i)return false;if(tag==3){if(l>MAX_NDEF)return false;memcpy(out,memory+i,l);len=l;return ndefValid(out,len);}i+=l;}
 return false;
}
void readNFC(){
 if(!nfcOK){note("PN532 unavailable");return;}show("Tap public NFC tag\nBACK cancels");
 uint8_t p[]={1,0},r[64],nd[MAX_NDEF];size_t n=0;
 if(!pnCommand(0x4A,p,2,r,sizeof(r),n,10000)||n<6||r[0]!=1){note("No tag / cancelled");return;}
 uint8_t u=r[5];if((u!=4&&u!=7&&u!=10)||n<size_t(6+u)){note("Invalid UID");return;}
 memcpy(uid,r+6,u);uidLen=u;
 uint16_t length=0;bool ok=(r[4]&0x20)?readType4(nd,length):readType2(nd,length);
 // Never leave stale NDEF attached to a newly scanned UID.
 ndefLen=0;if(ok){memcpy(ndef,nd,length);ndefLen=length;}
 note(ok?"UID + NDEF read":"UID only; no NDEF");
 show("UID "+hexBytes(uid,uidLen)+"\n"+statusText+"\nNDEF bytes "+String(ndefLen));waitAbort(1500);
}

// Read-only NFC Forum Type 4 application: NDEF AID, CC E103, NDEF E104.
size_t targetAPDU(const uint8_t *p,size_t n,uint8_t *out,bool &app,uint16_t &file){
 uint16_t sw=0x6D00;size_t used=0;
 const uint8_t aid[]={0xD2,0x76,0,0,0x85,1,1};
 const uint8_t cc[]={0,15,0x20,0,48,0,48,4,6,0xE1,4,2,2,0,0xFF};
 if(n<4)sw=0x6700;
 else if(p[0]!=0)sw=0x6E00;
 else if(p[1]==0xA4){
   sw=0x6A82;
   if(n>=5&&n>=size_t(5+p[4])){
     if(p[2]==4){app=false;file=0;
       if(p[4]==7&&!memcmp(p+5,aid,7)){app=true;sw=0x9000;}}
     else if(app&&p[2]==0&&p[4]==2){uint16_t f=(p[5]<<8)|p[6];
       if(f==0xE103||f==0xE104){file=f;sw=0x9000;}}
   }else sw=0x6700;
 }else if(p[1]==0xB0){
   sw=0x6986;
   if(app&&file){if(n!=5)sw=0x6700;else{
     size_t off=(p[2]<<8)|p[3],request=p[4]?p[4]:256;
     size_t size=file==0xE103?sizeof(cc):ndefLen+2;
     if(request>48)sw=0x6C30;
     else if(off>=size)sw=0x6B00;
     else{used=min(request,size-off);for(size_t i=0;i<used;i++){
       size_t at=off+i;out[i]=file==0xE103?cc[at]:(at==0?uint8_t(ndefLen>>8):(at==1?uint8_t(ndefLen):ndef[at-2]));}
       sw=used==request?0x9000:0x6282;}
   }}
 }else if(p[1]==0xD6)sw=0x6982; // read-only; never claim writable NDEF
 out[used++]=sw>>8;out[used++]=sw;return used;
}
void emulateNFC(){
 if(!nfcOK){note("PN532 unavailable");return;}
 show("Type 4 read-only\nUID 08"+hexBytes(targetTail,3)+"\nBACK cancels\n60 second session");
 uint8_t init[37]={5,0x04,0x00,targetTail[0],targetTail[1],targetTail[2],0x20};
 init[7]=1;init[8]=0xFE; // unused FeliCa parameters; passive ISO14443A only
 uint8_t r[240],send[64];size_t n=0;
 bool ok=pnCommand(0x8C,init,sizeof(init),r,sizeof(r),n,30000);
 bool app=false;uint16_t file=0;uint32_t start=millis();
 while(ok&&!cancelled()&&millis()-start<60000){
   ok=pnCommand(0x86,nullptr,0,r,sizeof(r),n,15000);
   if(!ok||n<2||r[0]!=0)break;
   send[0]=0x8E;size_t count=targetAPDU(r+1,n-1,send+1,app,file);
   ok=pnCommand(send[0],send+1,count,r,sizeof(r),n,1000)&&n>=1&&r[0]==0;
 }
 pnAbort();uint8_t sam[]={1,0x14,1};
 pnCommand(0x14,sam,3,r,sizeof(r),n,300);
 note(cancelled()?"Emulation cancelled":"Emulation ended");
}

void captureIR(){
 show("Point remote at RX\nPress remote button\nBACK cancels");receiver.resume();
 uint32_t start=millis();decode_results result;
 while(!cancelled()&&millis()-start<10000){
   if(receiver.decode(&result)){
     bool ok=!result.overflow&&result.rawlen>1&&result.rawlen-1<=MAX_IR;uint32_t total=0;
     uint16_t temp[MAX_IR];
     if(ok)for(int i=1;i<result.rawlen;i++){uint32_t us=uint32_t(result.rawbuf[i])*kRawTick;
       if(us<50||us>60000){ok=false;break;}temp[i-1]=us;total+=us;}
     if(total>2000000)ok=false;
     if(ok){pulseCount=result.rawlen-1;memcpy(pulses,temp,pulseCount*2);
       irInfo=result.decode_type==NEC?"NEC":(result.decode_type==SAMSUNG?"Samsung":"Raw/other");
       char val[24];snprintf(val,sizeof(val)," %llX",(unsigned long long)result.value);irInfo+=val;
       note(irInfo);}else note("IR too long / noise");
     receiver.resume();return;
   }delay(2);
 }note(cancelled()?"IR cancelled":"IR timeout");
}
void replayIR(){
 if(!pulseCount){note("No IR data");return;}receiver.disableIRIn();
 show("38 kHz IR replay\nBACK cancels");emitter.enableIROut(38,33);
 // Fixed 38 kHz marks; per-interval cancellation, <=60 ms bounded latency.
 for(uint16_t i=0;i<pulseCount&&!cancelled();i++){
   if(i&1)emitter.space(pulses[i]);else emitter.mark(pulses[i]);}
 emitter.space(0);receiver.enableIRIn();note(cancelled()?"Replay cancelled":"Replay complete");
}

// Manually launched DuckyScript subset only. No autorun, loops, includes or shell generation.
bool macroLine(const String &line,bool execute){
 if(!line.length()||line=="REM"||line.startsWith("REM "))return true;
 if(line.startsWith("STRING ")){
   String text=line.substring(7);for(size_t i=0;i<text.length();i++){
     if(text[i]<32||text[i]>126)return false;
     if(execute){if(cancelled())return false;keyboard.write((uint8_t)text[i]);if(!waitAbort(8))return false;}}
   return true;
 }
 if(line.startsWith("DELAY ")){uint32_t ms;if(!number(line.substring(6),60000,ms))return false;
   return !execute||waitAbort(ms);}
 if(line=="ENTER"){if(execute){if(cancelled())return false;keyboard.write(KEY_RETURN);}return true;}
 if(line=="GUI"||line.startsWith("GUI ")){
   String key=line.length()>3?line.substring(4):"";
   if(key.length()>1||(key.length()==1&&(key[0]<32||key[0]>126)))return false;
   if(execute){if(cancelled())return false;keyboard.press(KEY_LEFT_GUI);
     if(key.length())keyboard.press((uint8_t)key[0]);bool ok=waitAbort(30);keyboard.releaseAll();return ok;}return true;
 }return false;
}
void runMacro(){
 String script,line;size_t pos=0;
 if(!readText("/macro.txt",script)){note("No /macro.txt");return;}
 // Validate entire bounded script before generating any keystrokes.
 while(pos<script.length())if(!lineAt(script,pos,line,160)||!macroLine(line,false)){
   note("Invalid macro line");return;}
 show("USB macro ready\nUS keyboard layout\nRelease OK, then\npress OK to run\nBACK cancels");
 while(digitalRead(BUTTONS[2])==LOW){if(cancelled())return;delay(2);}
 uint32_t start=millis();bool confirmed=false;
 while(millis()-start<15000&&!cancelled()){
   if(digitalRead(BUTTONS[2])==LOW){confirmed=true;break;}delay(2);}
 if(!confirmed){note("Macro cancelled");return;}
 show("Macro running\nHold BACK to abort");pos=0;bool ok=true;
 while(pos<script.length()&&ok){ok=!cancelled()&&lineAt(script,pos,line,160)&&macroLine(line,true);if(ok)ok=waitAbort(5);}
 keyboard.releaseAll();note(ok?"Macro complete":"Macro aborted");
}
void setup(){
 for(int p:BUTTONS)pinMode(p,INPUT_PULLUP);
 pinMode(TX_PIN,OUTPUT);digitalWrite(TX_PIN,LOW);
 Wire.begin(SDA_PIN,SCL_PIN);Wire.setBufferSize(256);Wire.setTimeOut(50);
 displayOK=oled.begin(SSD1306_SWITCHCAPVCC,0x3C,true,false);
 if(displayOK){oled.setTextSize(1);oled.setTextColor(SSD1306_WHITE);oled.setTextWrap(true);}
 show("Starting lab tool");
 SPI.begin(SCK_PIN,MISO_PIN,MOSI_PIN,SD_CS);sdOK=SD.begin(SD_CS,SPI,4000000);
 // pn.begin() performs the library's I2C wakeup; no board power controls are used.
 if(pn.begin()){nfcOK=pn.getFirmwareVersion()!=0;
   if(nfcOK)nfcOK=pn.SAMConfig()&&pn.setPassiveActivationRetries(2);}
 keyboard.begin();USB.begin(); // Enumerates only; never sends keys at startup.
 emitter.begin();receiver.enableIRIn();
 note(String(sdOK?"SD OK":"SD missing")+" / "+(nfcOK?"NFC OK":"NFC missing"));
}
void loop(){
 static bool stable[4]={true,true,true,true},raw[4]={true,true,true,true};
 static uint32_t changed[4]={0,0,0,0};bool press[4]={false,false,false,false};
 for(int i=0;i<4;i++){bool v=digitalRead(BUTTONS[i]);
   if(v!=raw[i]){raw[i]=v;changed[i]=millis();}
   if(v!=stable[i]&&millis()-changed[i]>=25){stable[i]=v;press[i]=!v;}}
 if(press[0])selected=(selected+8)%9;if(press[1])selected=(selected+1)%9;
 if(press[3]){keyboard.releaseAll();note("Ready");}
 if(press[2]){
   switch(selected){case 0:captureIR();break;case 1:replayIR();break;
    case 2:saveIR();break;case 3:loadIR();break;case 4:readNFC();break;
    case 5:emulateNFC();break;case 6:saveNFC();break;case 7:loadNFC();break;case 8:runMacro();break;}
   // Absorb buttons held across an operation; require a fresh press next time.
   for(int i=0;i<4;i++){stable[i]=raw[i]=digitalRead(BUTTONS[i]);changed[i]=millis();}
 }
 show("Portable lab\n> "+String(menu[selected])+"\nUP/DOWN select\nOK run / BACK stop\n"+statusText);
 delay(2);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik