[DIY] 적외선 IR 리모컨을 스마트폰으로! 웹서버 기반 통합 리모컨 만들기 – IR Cloner

1. 스마트홈 제어 시스템의 필요성

현재까지 각 방에 온습도 센서를 배치하여 실시간 환경 데이터를 수집하는 모니터링 시스템을 운영해 왔습니다. 하지만 데이터의 시각화만으로는 능동적인 환경 조절에 한계가 있습니다. 수집된 데이터를 바탕으로 에어컨이나 선풍기 등의 가전제품을 물리적으로 조작할 수 있는 제어 수단이 결합되어야 비로소 완전한 스마트홈 시스템이 구축됩니다. 본 프로젝트에서는 이를 실현하기 위해 웹 기반 IR(적외선) 리모컨 제어기를 설계하고 구현하였습니다.

2. 핵심 기술적 구현 원리

① IR 신호의 정밀 제어 (PWM 및 캐리어 주파수)

적외선 리모컨 통신은 외부 광원과의 간섭을 차단하기 위해 특정 주파수(주로 38kHz)의 캐리어 신호에 데이터를 실어 보냅니다. 본 시스템에서는 machine.PWM 기능을 활용하여 38kHz의 주파수를 생성하였으며, 마이크로초($\mu s$) 단위의 정밀한 펄스 폭 변조를 통해 NEC 규격의 리딩 펄스와 데이터 비트를 구현하였습니다.

② 하드웨어 인터럽트(IRQ) 기반 신호 캡처

적외선 신호의 수신 시 발생하는 급격한 전압 변화를 놓치지 않기 위해 하드웨어 인터럽트(IRQ)를 사용하였습니다. 메인 루프에서 신호를 스캔하는 방식 대신, 핀의 상태 변화가 감지되는 즉시 타임스탬프를 기록하는 방식을 채택하여 CPU의 점유율을 최적화하고 수신 데이터의 정밀도를 확보하였습니다.

③ 웹소켓(WebSocket) 멀티 디바이스 통신

단방향 요청-응답 방식인 HTTP의 한계를 극복하기 위해 웹소켓 프로토콜을 적용하였습니다. 이를 통해 서버(ESP32)에서 수신된 IR 신호가 연결된 모든 클라이언트(스마트폰, PC 등)의 웹 화면에 실시간으로 동기화되도록 하였습니다. 또한 가변 길이의 데이터 프레임 헤더를 직접 설계하여, 데이터 양이 많은 펄스 파형 정보도 안정적으로 전송되도록 구현하였습니다.

3. 시스템 구성 및 아키텍처

장치의 부하를 줄이고 효율적인 제어를 위해 다음과 같은 하드웨어 및 소프트웨어 구조를 가집니다.

좌측: 적외선 수신모듈 VS1838B , 우측: 적외선 송신모듈 5mm IR LED
  • MCU: ESP32-C3 (내장 Wi-Fi를 통한 웹 서버 및 웹소켓 서버 구동)
  • 핀 맵: IR 수신기(GP0), IR 송신기(GP1)
  • UI/UX: HTML5와 CSS Grid를 활용한 반응형 웹 인터페이스를 구축하여 모바일 및 PC 환경에서 최적의 조작감을 제공합니다.
  • 데이터 관리: 브라우저의 localStorage를 활용하여 별도의 외부 DB 없이도 학습된 리모컨 히스토리를 유지하고 관리합니다.

4. 스마트홈 구현의 확장성

이번 IR 복사기 구축은 단순한 리모컨 통합 이상의 의미를 가집니다.

  • 원격 제어: 외부 네트워크 접속을 통해 온습도 상태에 따른 가전제품 원격 조작이 가능합니다.
  • 구형 가전의 스마트화: Wi-Fi 기능이 없는 구형 가전제품들을 기존 인프라에 통합하여 제어할 수 있습니다.
  • 자동화 시나리오의 토대: 온습도 센서의 임계값에 따라 IR 송신 신호를 자동으로 발생시키는 완전 자동화(Closed-loop control) 시스템으로의 확장이 가능해졌습니다.

5. 결론

이번 프로젝트를 통해 온습도 모니터링 시스템과 가전제품 제어 시스템을 단일 네트워크 내에서 통합하였습니다. 이는 스마트홈의 본질인 ‘데이터 기반의 능동적 환경 제어’를 실현하는 핵심적인 물리적 기반이 됩니다. 구축된 IR 제어기는 향후 다양한 센서와 연동되어 지능형 스마트홈 시스템의 중추적인 역할을 수행하게 됩니다.


방마다 있는 온습도 센서, ‘스마트 허브’로 업그레이드하기

스마트홈을 구축할 때 가장 기본이 되는 아이템이 바로 방마다 설치하는 온습도 센서입니다. 하지만 단순히 수치만 확인하는 용도로 쓰기엔 조금 아쉽죠. 여기에 ‘적외선(IR) LED’ 하나만 추가해 보세요. 놀라운 변화가 시작됩니다!

왜 적외선 LED인가요?

우리가 흔히 쓰는 에어컨, TV, 서큘레이터 등은 대부분 적외선 리모컨 방식을 사용합니다. 온습도 센서 모듈에 IR LED를 달아두면, 이 센서가 단순한 측정기를 넘어 ‘만능 리모컨 허브’ 역할을 하게 됩니다.

어떤 게 좋아지나요?

  1. 가전 통합 제어: 방마다 리모컨을 찾아다닐 필요 없이, 스마트폰이나 음성 명령 하나로 그 방의 모든 가전을 제어할 수 있습니다.
  2. 자동화 시나리오: “습도가 70%를 넘으면 제습기를 켜라”, “온도가 26도 이상이면 에어컨을 가동해라” 같은 스마트한 자동 제어가 완벽해집니다.
  3. 저비용 고효율: 비싼 스마트 가전을 새로 살 필요 없습니다. 기존 구형 가전들도 적외선 신호만 받을 수 있다면 즉시 스마트 가전이 됩니다.
  4. 집 밖 어디서나! 인터넷만 있다면 원격 제어 OK :
    “아, 리모컨은 방안에만 있어야 하는 거 아냐?”라고 생각하셨다면 오산입니다. 이 센서가 인터넷(Wi-Fi)에 연결되어 있다면, 여러분의 스마트폰이 전 세계 어디서든 작동하는 무선 리모컨이 됩니다. 퇴근길 미리 에어컨을 켜두어 ‘삶의 질’을 높이거나, 깜빡 잊고 외출한 가전제품을 밖에서 끄는 등 에너지와 시간을 획기적으로 아껴줍니다.

방마다 설치된 작은 센서가 우리 집 가전을 하나로 묶어주는 스마트홈의 핵심 컨트롤러가 되는 셈이죠. 지금 바로 적외선 LED 하나로 더 편리한 일상을 설계해 보세요!


Python boot.py

import network
import time
import machine

# --- 사용자 설정 (여기를 수정하세요) ---
SSID = "WiFi_이름"        # 와이파이 이름
PASSWORD = "WiFi_비밀번호"  # 와이파이 비밀번호
# ---------------------------------

def do_connect():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    
    if not wlan.isconnected():
        print('Connecting to network...', SSID)
        wlan.connect(SSID, PASSWORD)
        
        # 연결될 때까지 최대 10초 대기
        max_wait = 10
        while max_wait > 0:
            if wlan.isconnected():
                break
            max_wait -= 1
            print('Waiting for connection...')
            time.sleep(1)
            
    if wlan.isconnected():
        print('Network config:', wlan.ifconfig())
        # 연결 성공 시 내장 LED(또는 8번 핀)를 짧게 3번 깜빡여 알림
        led = machine.Pin(8, machine.Pin.OUT)
        for _ in range(3):
            led.value(0); time.sleep_ms(100); led.value(1); time.sleep_ms(100)
    else:
        print('Connection failed. Please check SSID/Password.')
        # 연결 실패 시 5초 후 시스템 리셋 (선택 사항)
        # time.sleep(5)
        # machine.reset()

# Wi-Fi 실행
do_connect()

Python main.py

import machine, time, _thread, socket, json, uhashlib, ubinascii, gc

# --- 1. 설정 및 하드웨어 초기화 ---
pulse_data = []
decoded_hex = "None"
last_tick = 0
is_recording = False
CAPTURE_TIMEOUT_US = 30000 
ws_clients = []

ir_rx = machine.Pin(0, machine.Pin.IN, machine.Pin.PULL_UP)
ir_tx = machine.PWM(machine.Pin(1), freq=38000, duty=0)
led_pin = machine.Pin(8, machine.Pin.OUT); led_pin.value(1)

# --- 2. 웹 인터페이스 (버튼 순서: 뒤로 추가) ---
html_page = """
<!DOCTYPE html><html><head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
    body { 
        background:#0a0a0c; color:#00ff88; text-align:center; 
        font-family: -apple-system, sans-serif; padding:10px; margin:0; 
    }
    .card { 
        background:#16161a; padding:15px; border-radius:15px; 
        max-width:95%; margin:10px auto; border:1px solid #333;
    }
    h2 { font-size: 1.3rem; margin: 10px 0; color: #fff; }
    #hex-display { 
        font-size:1.5rem; font-weight:bold; margin:10px 0; 
        padding: 10px; background: #000; border-radius: 10px; border: 1px dashed #444;
    }
    canvas { 
        background:#000; border:1px solid #444; width:100%; height:100px; 
        border-radius:8px; margin-bottom:15px; 
    }
    .btn-all-del { 
        background:#ff4444; color:white; border:none; padding:12px; 
        border-radius:10px; cursor:pointer; font-weight:bold; 
        font-size: 0.9rem; margin-bottom: 15px; width:100%;
    }
    
    /* 핵심 수정: 반응형 그리드 설정 */
    .history-container { 
        display: grid; 
        /* 폰에서 2열이 예쁘게 나오도록 최소 너비 살짝 조정 */
        grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); 
        gap: 8px; 
        margin-top: 10px; 
    }
    
    .h-item { 
        background:#2a2a2e; 
        padding: 18px 5px; /* 위아래로 큼직하게 */
        border-radius:10px; 
        border:1px solid #444; color:#fff; cursor:pointer; 
        font-size: 0.95rem; /* 한 줄에 다 들어가도록 폰트 소폭 조정 */
        display: flex; 
        flex-direction: row; /* 가로 방향 배치 */
        align-items: center; 
        justify-content: center;
        white-space: nowrap; /* 글자 줄바꿈 방지 */
        overflow: hidden;
        box-shadow: 0 2px 4px rgba(0,0,0,0.2);
    }
    
    .h-item:active { background:#00ff88; color:#000; transform: scale(0.95); }
    
    .h-idx { 
        color:#00ff88; 
        font-weight:bold; 
        margin-right: 8px; /* 순번과 HEX 사이 간격 */
        font-size: 0.85rem; 
        flex-shrink: 0; /* 순번 영역이 좁아지지 않게 고정 */
    }
    
    /* 모바일 가로모드나 태블릿 대응 */
    @media (min-width: 600px) {
        .history-container { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
        .h-item { font-size: 1.1rem; }
    }
</style></head>
<body>
    <div class="card">
        <h2>🛰️ 스마트 리모컨</h2>
        <div id="hex-display">READY</div>
        <canvas id="cvs" width="1200" height="150"></canvas>
        
        <button class="btn-all-del" onclick="clearAll()">🗑️ 기록 전부 삭제</button>
        <div id="history" class="history-container"></div>
    </div>
    <script>
    let ws, nextIdx = parseInt(localStorage.getItem('ir_idx') || '1');
    let historyData = JSON.parse(localStorage.getItem('ir_hist') || '[]');

    function save() { 
        localStorage.setItem('ir_hist', JSON.stringify(historyData)); 
        localStorage.setItem('ir_idx', nextIdx.toString()); 
    }

    function render() {
        const list = document.getElementById('history'); list.innerHTML = '';
        historyData.forEach((item) => {
            const div = document.createElement('div');
            div.className = 'h-item';
            div.innerHTML = `<span class="h-idx">[${item.idx}]</span> ${item.hex}`;
            div.onclick = () => {
                // 시각적 피드백
                div.style.background = "#00ff88"; div.style.color = "#000";
                setTimeout(() => { div.style.background = "#2a2a2e"; div.style.color = "#fff"; }, 200);
                fetch('/send?code=' + item.hex);
            };
            list.appendChild(div);
        });
    }

    function clearAll() { if(confirm("모든 히스토리를 삭제할까요?")) { historyData=[]; nextIdx=1; save(); render(); } }

    function connect() {
        ws = new WebSocket('ws://' + window.location.host);
        ws.onmessage = (e) => {
            const d = JSON.parse(e.data);
            document.getElementById('hex-display').innerText = d.hex;
            if(!historyData.find(i => i.hex === d.hex)) {
                historyData.push({idx: nextIdx++, hex: d.hex});
                save(); render();
            }
            draw(d.pulses);
        };
        ws.onclose = () => setTimeout(connect, 2000);
    }

    function draw(pulses) {
        const c = document.getElementById('cvs'), ctx = c.getContext('2d');
        ctx.clearRect(0,0,c.width,c.height); ctx.strokeStyle = '#00ff88'; ctx.lineWidth = 3;
        let x = 0; const scaleX = c.width / pulses.reduce((a,b)=>a+b, 0);
        ctx.beginPath(); ctx.moveTo(0,100);
        pulses.forEach((v, i) => {
            let y = (i % 2 === 0) ? 20 : 100;
            ctx.lineTo(x, y); x += v * scaleX; ctx.lineTo(x, y);
        });
        ctx.stroke();
    }
    render(); connect();
    </script></body></html>
"""

# --- 3. 통신 및 웹소켓 헤더 처리 ---
# --- 3. 멀티 클라이언트 대응 웹소켓 및 서버 ---
def broadcast_ws(msg_obj):
    """접속된 모든 기기에 실시간 데이터 전송"""
    try:
        msg = json.dumps(msg_obj).encode()
        length = len(msg)
        # 웹소켓 프레임 헤더 생성 (가변 길이 대응)
        if length <= 125:
            header = bytearray([0x81, length])
        elif length <= 65535:
            header = bytearray([0x81, 126, (length >> 8) & 0xFF, length & 0xFF])
        else:
            header = bytearray([0x81, 127, 0,0,0,0, (length >> 24) & 0xFF, (length >> 16) & 0xFF, (length >> 8) & 0xFF, length & 0xFF])
        
        frame = header + msg
        # 현재 연결된 모든 클라이언트(폰, PC 등)에게 전송
        for cl in ws_clients[:]:
            try:
                cl.send(frame)
            except:
                try: cl.close()
                except: pass
                if cl in ws_clients: ws_clients.remove(cl)
    except Exception as e:
        print("Broadcast error:", e)

def web_server():
    s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(('', 80)); s.listen(5) # 동시 접속 대기 수 확대
    print("Server Started... Multiple connections allowed.")
    
    while True:
        gc.collect()
        cl = None
        try:
            cl, addr = s.accept()
            cl.settimeout(2.0)
            req = cl.recv(1024).decode()
            
            # 1. 웹소켓 핸드쉐이크 (여러 기기 접속 허용)
            if "Upgrade: websocket" in req:
                key = [l.split(': ')[1] for l in req.split('\r\n') if "Sec-WebSocket-Key:" in l][0]
                accept = ubinascii.b2a_base64(uhashlib.sha1(key.encode() + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest()).decode().strip()
                cl.send("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n")
                ws_clients.append(cl)
                print(f"New Device Connected: {addr}")
                
            # 2. 버튼 클릭 시 IR 송신 요청 처리
            elif "GET /send" in req:
                code = req.split("code=")[1].split(" ")[0]
                cl.send("HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nOK")
                cl.close()
                transmit_nec(code)
                
            # 3. 일반 웹페이지 접속
            else:
                cl.send("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n" + html_page)
                cl.close()
        except Exception as e:
            if cl: cl.close()

def transmit_nec(hex_str):
    val = int(hex_str.replace("0x", ""), 16)
    ir_rx.irq(handler=None)
    state = machine.disable_irq()
    try:
        start = time.ticks_us(); curr_offset = 0
        def pulse(h, l):
            nonlocal curr_offset
            ir_tx.duty(512); curr_offset += h
            while time.ticks_diff(time.ticks_us(), start) < curr_offset: pass
            ir_tx.duty(0); curr_offset += l
            while time.ticks_diff(time.ticks_us(), start) < curr_offset: pass
        pulse(9000, 4500)
        for i in range(31, -1, -1):
            bit = (val >> i) & 1
            pulse(560, 1690 if bit == 1 else 560)
        pulse(560, 0)
    finally:
        machine.enable_irq(state)
        ir_tx.duty(0)
        ir_rx.irq(handler=irq_handler, trigger=machine.Pin.IRQ_RISING | machine.Pin.IRQ_FALLING)


def irq_handler(pin):
    global last_tick, pulse_data, is_recording
    curr = time.ticks_us()
    diff = time.ticks_diff(curr, last_tick)
    last_tick = curr
    if diff < 100: return
    if not is_recording:
        if 7000 < diff < 11000: pulse_data = [diff]
        elif len(pulse_data) == 1 and 1500 < diff < 6000:
            pulse_data.append(diff); is_recording = True
    else:
        if len(pulse_data) < 150: pulse_data.append(diff)

# --- 4. 시작 ---
ir_rx.irq(handler=irq_handler, trigger=machine.Pin.IRQ_RISING | machine.Pin.IRQ_FALLING)
_thread.start_new_thread(web_server, ())

while True:
    if len(pulse_data) > 0 and time.ticks_diff(time.ticks_us(), last_tick) > CAPTURE_TIMEOUT_US:
        p_len = len(pulse_data)
        if p_len >= 60:
            try:
                bits = 0
                for i in range(2, p_len-1, 2):
                    if i+1 < p_len:
                        bits <<= 1
                        if pulse_data[i+1] > 1050: bits |= 1
                hex_val = "0x{:08X}".format(bits & 0xFFFFFFFF)
                broadcast_ws({"hex": hex_val, "pulses": pulse_data})
                led_pin.value(0); time.sleep_ms(50); led_pin.value(1)
            except: pass
        pulse_data = []; is_recording = False
    time.sleep_ms(10)

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다