와이파이 지원 IR 블라스터: ESP8266과 아두이노로 DIY 스마트 리모컨 만들기
부품 및 소모품
부품
수량
비고
ESP8266 ESP-01 (또는 NodeMCU 개발 키트)
1
NodeMCU가 전원 레귤레이터, USB-시리얼 내장으로 더 편리
아두이노 나노 R3
1
범용 NPN 트랜지스터 (예: 2N2222)
1
IR LED 구동용
Adafruit IR LED
1
필요 도구
앱 및 온라인 서비스
아두이노 IDE
NodeMCU 펌웨어 (ESP8266용 Lua 펌웨어, 선택 사항)
프로젝트 소개
시작 동기
최신 스마트폰에서 IR 블라스터가 사라지면서, IoT 인프라와 연동 안 되는 '구형' 가전제품을 제어할 방법이 필요했습니다. 메쉬 네트워크 스마트 홈을 구축했지만 TV 켜려면 리모컨을 찾아야 하는 상황, 더 나은 방법이 있어야죠.
이미 에어컨용 IR 블라스터와 안드로이드 웹앱 연동 HVAC 벤트 프로젝트를 진행한 바 있습니다. 이 둘을 합쳐 웹 연결 IR 블라스터 + 웹앱 프론트엔드 를 만들면 스마트폰 버튼 하나로 집안 모든 IR 기기 제어 가능. 방마다 설치해 단일 인터페이스로 통합 제어하면 활용도가 매우 넓습니다.
빠른 프로토타이핑
초기 브레드보드 프로토타입
ESP8266에 AT 명령으로 와이파이 연결, HTTP 요청 수신, IR 신호 송출까지 구현했으나 1~2초 지연 발생. AT 명령 기반이라 번거로움.
시리얼 모니터상 지연 현상
NodeMCU로 전환
NodeMCU ESP8266 개발 보드 채택: 온보드 전원 레귤레이터, USB-시리얼, 풍부한 I/O, 브레드보드 친화적. ESP8266WebServer 라이브러리 로 GET 요청 수신, IRremote 라이브러리 로 IR 송출 모두 바로 작동.
NodeMCU 개발 보드
웹앱은 별도 서버(예: C.H.I.P. $9 컴퓨터, 아파치 구동)에서 호스팅
ESP8266은 3.3V 로직이라 IR LED 직결 시 광량 부족 → NPN 트랜지스터로 5V 전원 스위칭해 해결
REST-ish API 설계
Adafruit의 아두이노 REST API 튜토리얼 참고. 자바스크립트/PHP(cURL)로 요청 전송, 안드로이드 네이티브 웹앱으로 설치 가능하도록 manifest.json, 아이콘 추가.
웹앱 리모컨 UI
버튼 배치 상세
manifest.json으로 네이티브 앱처럼 설치
아두이노 측: GET 요청 수신 → URL 파싱 → 200 OK 응답 → 연결 종료 → 매핑된 RAW IR 코드 송출. 지연 제로 달성.
유니버설 리몬스터 (Universal Remonster)
선반 아래 벨크로로 고정, IR 수신부 정향
두꺼운 게이지 와이어로 LED 각도 미세 조정
완성된 IR 블라스터
IR LED 협각이라 정향 중요. 저항 제거하고 트랜지스터로 5V 직결해 벽면 반사 신호도 수신 가능하게 함. 수명 단축 우려 있으나 듀티 사이클이 극히 낮아(펄스 폭 짧음) 당분간 괜찮을 듯. V2에서는 광각/고출력 IR LED와 인라인 저항 적용 예정.
3개월 사용 후기 & 향후 계획
매일 사용 중, 무결점 작동. 거실 에어컨/TV 제어용 2대 추가 설치.
웹 UI 클라우드 서비스 이전
아마존 에코 연동 음성 제어 (완료)
차기 하드웨어: DHT22 온습도 센서 추가, 천장 팬용 RF 송신 연구
IR 수신기 추가로 기존 리모컨 '학습' 기능 구현
홈 오토메이션/보안 연동: 미사용 시 가전 자동 끄기
이 작은 커넥티드 IR 블라스터가 스마트 홈과 레거시 가전 사이의 간극을 점점 메워가고 있습니다.
코드
아두이노 스케치 (WiFi IR Blaster for ESP8266)
/* WiFi IR Blaster by Buddy Crotty */
#include <ESP8266WiFi.h>
#include <IRremoteESP8266.h>
#include <ESP8266mDNS.h>
const char* ssid = "AP_SSID";
const char* password = "AP_Pass";
MDNSResponder mdns;
int khz = 38; // 38kHz 캐리어
IRsend irsend(4); // GPIO4 (NodeMCU D2)
/* RAW IR 코드들 (삼성 TV, NEC 리시버 등) */
unsigned int irTVpwr[] = {4650,4250, 700,1550, ... };
unsigned int irTVsrc[] = {4600,4300, 700,1550, ... };
// ... (생략: irTVmute, irTVvdn, irTVvup, irTVchup, irTVchdn,
// irRECpwr, irRECpwrON, irRECpwrOFF, irRECmute, irRECvdn, irRECvup, irRECsrc)
WiFiServer server(80);
void setup() {
Serial.begin(115200); delay(10);
irsend.begin();
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.println(""); Serial.println("WiFi Connected");
server.begin(); Serial.println("HTTP Server Started");
Serial.print("IP Address: "); Serial.println(WiFi.localIP());
if (mdns.begin("IRBlasterLR", WiFi.localIP())) Serial.println("MDNS Responder Started");
}
void loop() {
WiFiClient client = server.available();
if (!client) return;
Serial.println("new client");
while(!client.available()) delay(1);
String req = client.readStringUntil('\r');
Serial.println(req); client.flush();
if (req.indexOf("/irTVpwr") != -1) { irsend.sendRaw(irTVpwr, sizeof(irTVpwr)/sizeof(irTVpwr[0]), khz); Serial.println("IRreq irTVpwr sent"); }
else if (req.indexOf("/irTVsrc") != -1) { irsend.sendRaw(irTVsrc, sizeof(irTVsrc)/sizeof(irTVsrc[0]), khz); Serial.println("IRreq irTVsrc sent"); }
else if (req.indexOf("/irTVmute") != -1) { irsend.sendRaw(irTVmute, sizeof(irTVmute)/sizeof(irTVmute[0]), khz); Serial.println("IRreq irTVmute sent"); }
else if (req.indexOf("/irTVvdn") != -1) { irsend.sendRaw(irTVvdn, sizeof(irTVvdn)/sizeof(irTVvdn[0]), khz); Serial.println("IRreq irTVvdn sent"); }
else if (req.indexOf("/irTVvup") != -1) { irsend.sendRaw(irTVvup, sizeof(irTVvup)/sizeof(irTVvup[0]), khz); Serial.println("IRreq irTVvup sent"); }
else if (req.indexOf("/irTVchup") != -1) { irsend.sendRaw(irTVchup, sizeof(irTVchup)/sizeof(irTVchup[0]), khz); Serial.println("IRreq irTVchup sent"); }
else if (req.indexOf("/irTVchdn") != -1) { irsend.sendRaw(irTVchdn, sizeof(irTVchdn)/sizeof(irTVchdn[0]), khz); Serial.println("IRreq irTVchdn sent"); }
else if (req.indexOf("/irALLpwr") != -1) { irsend.sendRaw(irRECpwrON, sizeof(irRECpwrON)/sizeof(irRECpwrON[0]), khz); irsend.sendRaw(irTVpwr, sizeof(irTVpwr)/sizeof(irTVpwr[0]), khz); delay(2000); irsend.sendRaw(irRECsrc, sizeof(irRECsrc)/sizeof(irRECsrc[0]), khz); Serial.println("IRreq irALLpwr sent"); }
else if (req.indexOf("/irRECpwr") != -1) { irsend.sendRaw(irRECpwr, sizeof(irRECpwr)/sizeof(irRECpwr[0]), khz); Serial.println("IRreq irRECpwr sent"); }
else if (req.indexOf("/irRECpwrON") != -1) { irsend.sendRaw(irRECpwrON, sizeof(irRECpwrON)/sizeof(irRECpwrON[0]), khz); Serial.println("IRreq irRECpwrON sent"); }
else if (req.indexOf("/irRECpwrOFF") != -1) { irsend.sendRaw(irRECpwrOFF, sizeof(irRECpwrOFF)/sizeof(irRECpwrOFF[0]), khz); Serial.println("IRreq irRECpwrOFF sent"); }
else if (req.indexOf("/irRECmute") != -1) { irsend.sendRaw(irRECmute, sizeof(irRECmute)/sizeof(irRECmute[0]), khz); Serial.println("IRreq irRECmute sent"); }
else if (req.indexOf("/irRECvdn") != -1) { irsend.sendRaw(irRECvdn, sizeof(irRECvdn)/sizeof(irRECvdn[0]), khz); Serial.println("IRreq irRECvdn sent"); }
else if (req.indexOf("/irRECvup") != -1) { irsend.sendRaw(irRECvup, sizeof(irRECvup)/sizeof(irRECvup[0]), khz); Serial.println("IRreq irRECvup sent"); }
else { Serial.println("invalid request"); client.stop(); return; }
client.flush();
client.print("HTTP/1.1 200 OK\r\n"); delay(1);
Serial.println("Client Disconnected");
}
script.js (웹앱 자바스크립트, jQuery 필요)
function buttonClick(clicked_id) {
var ip = "192.168.1.62"; // ESP8266 IP
if (clicked_id == "irTVpwr") { $.get("curl.php", { room: ip, button: "irTVpwr" }); }
else if (clicked_id == "irTVsrc") { $.get("curl.php", { room: ip, button: "irTVsrc" }); }
else if (clicked_id == "irTVmute") { $.get("curl.php", { room: ip, button: "irTVmute" }); }
else if (clicked_id == "irTVvdn") { $.get("curl.php", { room: ip, button: "irTVvdn" }); }
else if (clicked_id == "irTVvup") { $.get("curl.php", { room: ip, button: "irTVvup" }); }
else if (clicked_id == "irTVchup") { $.get("curl.php", { room: ip, button: "irTVchup" }); }
else if (clicked_id == "irTVchdn") { $.get("curl.php", { room: ip, button: "irTVchdn" }); }
else if (clicked_id == "irRECpwr") { $.get("curl.php", { room: ip, button: "irRECpwr" }); }
else if (clicked_id == "irALLpwr") { $.get("curl.php", { room: ip, button: "irALLpwr" }); }
else if (clicked_id == "irRECpwrON") { $.get("curl.php", { room: ip, button: "irRECpwrON" }); }
else if (clicked_id == "irRECpwrOFF") { $.get("curl.php", { room: ip, button: "irRECpwrOFF" }); }
else if (clicked_id == "irRECmute") { $.get("curl.php", { room: ip, button: "irRECmute" }); }
else if (clicked_id == "irRECvdn") { $.get("curl.php", { room: ip, button: "irRECvdn" }); }
else if (clicked_id == "irRECvup") { $.get("curl.php", { room: ip, button: "irRECvup" }); }
}
index.html (웹앱 UI)
<html>
<head>
<link href="style.css" rel="stylesheet" type="text/css" />
<script src="jquery-2.0.3.min.js"></script>
<script src="script.js"></script>
<link rel="manifest" href="manifest.json">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
</head>
<body>
<div align="center" class="div_text_shadow">
<button class="btn2" id="irALLpwr" onClick="buttonClick(this.id)">Power Up Everything</button><br><br><br>
<h1>TV</h1>
<button class="btn" id="irTVpwr" onClick="buttonClick(this.id)">Power</button><br><br>
<button class="btn" id="irTVvup" onClick="buttonClick(this.id)">Vol +</button>
<button class="btn" id="irTVsrc" onClick="buttonClick(this.id)">Source</button>
<button class="btn" id="irTVchup" onClick="buttonClick(this.id)">Ch +</button><br><br>
<button class="btn" id="irTVvdn" onClick="buttonClick(this.id)">Vol -</button>
<button class="btn" id="irTVmute" onClick="buttonClick(this.id)">Mute</button>
<button class="btn" id="irTVchdn" onClick="buttonClick(this.id)">Ch -</button>
<br><br><br>
<h1>Receiver</h1>
<button class="btn" id="irRECpwr" onClick="buttonClick(this.id)">Power</button><br><br>
<button class="btn" id="irRECmute" onClick="buttonClick(this.id)">Mute</button>
<button class="btn" id="irRECvdn" onClick="buttonClick(this.id)">Vol -</button>
<button class="btn" id="irRECvup" onClick="buttonClick(this.id)">Vol +</button>
</div>
</body>
</html>
curl.php (프록시 PHP)
<?php
$room = $_GET['room'];
$button = $_GET['button'];
$service_url = 'https://' . $room . '/' . $button;
$curl = curl_init($service_url);
curl_setopt($curl, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
$curl_response = curl_exec($curl);
curl_close($curl);
echo $curl_response;
?>
manifest.json (안드로이드 웹앱 매니페스트)
{
"name": "WiFi Remote",
"icons": [
{"src": "remote_icon_36.png", "sizes": "36x36", "type": "image/png", "density": 0.75},
{"src": "remote_icon_48.png", "sizes": "48x48", "type": "image/png", "density": 1.0},
{"src": "remote_icon_128.png", "sizes": "128x128", "type": "image/png", "density": 1.0},
{"src": "remote_icon_192.png", "sizes": "192x192", "type": "image/png", "density": 1.0}
],
"scope": "/remote/",
"start_url": "/remote/index.html",
"display": "fullscreen",
"orientation": "portrait"
}
회로도
5V 전원에서 NPN 트랜지스터로 직렬 IR LED 2개 구동 (저항 없음)
여러 IR LED 병렬 구동용: NPN이 PNP 트랜지스터들 스위칭 (전압 강하 최소화)