Điều khiển TV bằng điện thoại qua WiFi (học lệnh từ remote cũ)

Biến điện thoại thành remote TV, đầu thu, quạt, điều hòa đời cũ… dùng hồng ngoại. Mạch học lệnh từ remote gốc, sau đó bấm trên web điện thoại (không cần cài app). Tầm phát 6–8m nhờ 2 LED hồng ngoại có transistor khuếch dòng.

⏱ 90 phút🔧 Biết hàn💰 Linh kiện ~118.700đ🧠 ESP32 DevKit V1⚡ Nguồn USB 5V

✅ Đã kiểm thử

Luật điệnKhông lỗiSai chân, chập nguồn, quá áp 5V vào chân 3,3V, thiếu đi-ốt, thiếu trở kéo…
Mô phỏng SPICE2/2 kịch bản · 10 phép đo đạtDòng/áp từng linh kiện ở trường hợp xấu nhất (ngspice)
Biên dịch codeĐạt · dùng 80% bộ nhớesp32:esp32:esp32doit-devkit-v1
Xem chi tiết 10 phép đo

Đang phát lệnh (chân GPIO4 mức cao — trường hợp xấu nhất)

  • Q1: dẫn 77.4mA, Vce=0.08V (bão hòa tốt).
  • D1: dòng 77.4mA (cho phép 100.0mA).
  • D2: dòng 77.4mA (cho phép 100.0mA).
  • Chân GPIO4 cấp 2.4mA (an toàn ≤ 20.0mA).
  • Tổng dòng đỉnh từ USB 331.3mA (đủ với cục sạc 5V 1A).
  • Dòng qua LED hồng ngoại (cần 50–100mA để phát xa 6–8m): 77.4mA ✓
  • Dòng chân GPIO4 phải cấp: 2.4mA ✓
  • Áp tín hiệu mắt thu vào ESP32: 3.16V ✓

Nghỉ (không phát)

  • Tổng dòng đỉnh từ USB 251.4mA (đủ với cục sạc 5V 1A).
  • LED tắt hẳn khi nghỉ: 0.0mA ✓
Kiểm lần cuối: 16:29:11 12/11/2026

Nguyên lý hoạt động

ESP32 phát sóng WiFi và chạy một trang web nhỏ. Khi bạn bấm nút trên điện thoại, ESP32 bật tắt chân GPIO4 theo nhịp 38kHz đúng mã lệnh của TV. Dòng từ chân GPIO4 chỉ khoảng 2,5mA (qua trở 1kΩ) để mở transistor S8050; transistor mới là thứ cấp ~75mA cho 2 LED hồng ngoại mắc nối tiếp, nên phát xa mà không làm hỏng ESP32. Mắt thu VS1838B được cấp 3,3V (qua lọc 100Ω + 10µF) để tín hiệu ra không vượt 3,3V, dùng để học lệnh từ remote gốc.

Sơ đồ đấu dây

Mỗi màu là một dây. Chấm tròn là điểm hàn. Chạm vào tên dây trong bảng để làm nổi bật.

5V5V3V33V3GNDGNDIR_BASE_DRVIR_BASE_DRVIR_BASEIR_BASEIR_A1IR_A1IR_MIDIR_MIDIR_COLIR_COLRX_VCCRX_VCCRX_OUTRX_OUTU1 · ESP32 DevKit V13V3VINGPIO4 (D4)GPIO27 (D27)GNDR1 · Điện trở 1kΩ12Q1 · Transistor NPN S8050B (gốc)C (góp)E (phát)D2 · LED phát hồng ngoại 940nm 5mmA · chân dài (+)K · chân ngắn (−)U2 · Mắt thu hồng ngoại VS1838B 38kHzVCC · chân 3 VCCOUT · chân 1 OUTGND · chân 2 GNDD1 · LED phát hồng ngoại 940nm 5mmA · chân dài (+)K · chân ngắn (−)R3 · Điện trở 100Ω12C1 · Tụ điện 10µF-+R2 · Điện trở 27Ω12
#DâyNối các điểm
1đỏ
5V
U1 VIN ⟷ R2 1
2cam
3V3
U1 3V3 ⟷ R3 1
3đen
GND
U1 GND ⟷ Q1 E (phát) ⟷ U2 GND · chân 2 GND ⟷ C1 -
4vàng
IR_BASE_DRV
U1 GPIO4 (D4) ⟷ R1 1
5xanh lá
IR_BASE
R1 2 ⟷ Q1 B (gốc)
6xanh dương
IR_A1
R2 2 ⟷ D1 A · chân dài (+)
7tím
IR_MID
D1 K · chân ngắn (−) ⟷ D2 A · chân dài (+)
8xanh ngọc
IR_COL
D2 K · chân ngắn (−) ⟷ Q1 C (góp)
9nâu
RX_VCC
R3 2 ⟷ U2 VCC · chân 3 VCC ⟷ C1 +
10hồng
RX_OUT
U2 OUT · chân 1 OUT ⟷ U1 GPIO27 (D27)

Danh sách linh kiện

Ký hiệuLinh kiệnGiá tham khảo
U1ESP32 DevKit V1 30 chân (ESP32-WROOM-32, cổng USB-C hoặc Micro USB)110.000đTìm mua
Q1Transistor S8050 (TO-92) — thay được bằng 2N2222500đTìm mua
R1Điện trở 1kΩ 1/4W (nâu-đen-đỏ)200đTìm mua
R2Điện trở 27Ω 1/2W (đỏ-tím-đen)300đTìm mua
D1LED phát hồng ngoại 940nm 5mm (vỏ trong suốt)1.000đTìm mua
D2LED phát hồng ngoại 940nm 5mm (vỏ trong suốt)1.000đTìm mua
U2Mắt thu hồng ngoại VS1838B 38kHz (3 chân)5.000đTìm mua
R3Điện trở 100Ω 1/4W (nâu-đen-nâu)200đTìm mua
C1Tụ hóa 10µF 16V (10u)500đTìm mua
Tổng (chưa gồm board hàn, dây, thiếc)118.700đ

Các bước làm

Code nạp cho mạch

Cài thư viện: IRremoteESP8266 (Arduino IDE → Library Manager). Chọn board DOIT ESP32 DEVKIT V1.

/*
  Mạch Gọn — Điều khiển TV/thiết bị hồng ngoại bằng điện thoại qua WiFi
  Board : ESP32 DevKit V1 (chọn "DOIT ESP32 DEVKIT V1")
  Thư viện: IRremoteESP8266 (>= 2.9.0)
  Chân  : GPIO4 -> R 1k -> B transistor S8050 (2 LED IR ở cực C)
          GPIO27 <- OUT mắt thu VS1838B (cấp 3,3V)
  Lần đầu: vào WiFi "MachGon-TV" (mật khẩu machgon123) -> mở 192.168.4.1
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <nvs.h>
#include <nvs_flash.h>
#include <IRremoteESP8266.h>
#include <IRrecv.h>
#include <IRsend.h>
#include <IRutils.h>

const uint16_t PIN_IR_TX = 4;
const uint16_t PIN_IR_RX = 27;
const uint8_t  PIN_LED   = 2;      // LED xanh có sẵn trên board
const char*    AP_SSID   = "MachGon-TV";
const char*    HOSTNAME  = "tivi";
const uint32_t LEARN_MS  = 10000;
const uint16_t RAW_MAX   = 600;    // số mốc thời gian tối đa của 1 lệnh thô

IRsend irsend(PIN_IR_TX);
IRrecv irrecv(PIN_IR_RX, 1024, 50, true);
decode_results results;
WebServer server(80);
Preferences codes, cfg;

enum LearnState { L_IDLE, L_WAIT, L_DONE, L_TIMEOUT };
LearnState learnState = L_IDLE;
String learnId, learnProto;
uint32_t learnStart = 0;
bool forceRaw = false;

// Bố cục: 1 byte kind | 2 byte type | 2 byte bits | 8 byte value | 2 byte len | payload
enum Kind : uint8_t { K_VALUE = 1, K_STATE = 2, K_RAW = 3 };

static bool validId(const String& id) {
  if (id.length() < 1 || id.length() > 15) return false;
  for (char ch : id) if (!(isalnum(ch) || ch == '_')) return false;
  return true;
}

void blink(int n, int ms = 60) {
  for (int i = 0; i < n; i++) { digitalWrite(PIN_LED, HIGH); delay(ms); digitalWrite(PIN_LED, LOW); delay(ms); }
}

bool saveCode(const String& id, const decode_results& r) {
  std::vector<uint8_t> buf;
  auto put16 = [&](uint16_t v) { buf.push_back(v & 0xFF); buf.push_back(v >> 8); };
  uint8_t kind;
  bool known = r.decode_type != decode_type_t::UNKNOWN && !forceRaw;
  if (known && hasACState(r.decode_type)) kind = K_STATE;
  else if (known && r.bits <= 64) kind = K_VALUE;
  else kind = K_RAW;
  buf.push_back(kind);
  put16((uint16_t)(int16_t)r.decode_type);
  put16(r.bits);
  for (int i = 0; i < 8; i++) buf.push_back((r.value >> (8 * i)) & 0xFF);
  if (kind == K_STATE) {
    uint16_t nbytes = r.bits / 8;
    put16(nbytes);
    for (uint16_t i = 0; i < nbytes; i++) buf.push_back(r.state[i]);
  } else if (kind == K_RAW) {
    uint16_t len = getCorrectedRawLength(&r);
    if (len > RAW_MAX || len < 8) return false;
    uint16_t* raw = resultToRawArray(&r);
    put16(len);
    for (uint16_t i = 0; i < len; i++) put16(raw[i]);
    delete[] raw;
  } else {
    put16(0);
  }
  return codes.putBytes(id.c_str(), buf.data(), buf.size()) == buf.size();
}

bool sendCode(const String& id) {
  size_t n = codes.getBytesLength(id.c_str());
  if (n < 15) return false;
  std::vector<uint8_t> b(n);
  codes.getBytes(id.c_str(), b.data(), n);
  auto get16 = [&](size_t o) { return (uint16_t)(b[o] | (b[o + 1] << 8)); };
  uint8_t kind = b[0];
  decode_type_t type = (decode_type_t)(int16_t)get16(1);
  uint16_t bits = get16(3);
  uint64_t value = 0;
  for (int i = 0; i < 8; i++) value |= (uint64_t)b[5 + i] << (8 * i);
  uint16_t len = get16(13);
  digitalWrite(PIN_LED, HIGH);
  bool ok = false;
  if (kind == K_VALUE) ok = irsend.send(type, value, bits);
  else if (kind == K_STATE && n >= 15u + len) ok = irsend.send(type, &b[15], len);
  else if (kind == K_RAW && n >= 15u + 2u * len) {
    std::vector<uint16_t> raw(len);
    for (uint16_t i = 0; i < len; i++) raw[i] = get16(15 + 2 * i);
    irsend.sendRaw(raw.data(), len, 38);
    ok = true;
  }
  digitalWrite(PIN_LED, LOW);
  return ok;
}

bool sendPreset(const String& p) {
  digitalWrite(PIN_LED, HIGH);
  bool ok = true;
  if (p == "samsung") irsend.sendSAMSUNG(0xE0E040BF, 32);
  else if (p == "lg") irsend.sendNEC(0x20DF10EF, 32);
  else if (p == "sony") irsend.sendSony(0xA90, 12, 2);
  else if (p == "nec_generic") irsend.sendNEC(0x00FF02FD, 32);
  else ok = false;
  digitalWrite(PIN_LED, LOW);
  return ok;
}

// ---------------- Trang web ----------------
const char PAGE[] PROGMEM = R"HTML(<!doctype html><html lang="vi"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Remote WiFi</title>
<style>
:root{--b:#0f172a;--c:#1e293b;--a:#22c55e;--t:#e2e8f0;--m:#94a3b8}
*{box-sizing:border-box}body{margin:0;font-family:system-ui,sans-serif;background:var(--b);color:var(--t)}
header{display:flex;gap:8px;align-items:center;padding:12px 14px;background:var(--c);position:sticky;top:0}
header b{flex:1}select,input,button{font:inherit}
.tabs{display:flex;gap:6px;overflow:auto;padding:10px 14px}.tabs button{background:var(--c);color:var(--t);border:1px solid #334155;border-radius:20px;padding:6px 14px}
.tabs button.on{background:var(--a);color:#052e16;border-color:var(--a)}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;padding:6px 14px 14px}
.k{position:relative;background:var(--c);border:1px solid #334155;border-radius:14px;padding:16px 4px;text-align:center;color:var(--t);font-size:15px;min-height:58px}
.k.pwr{background:#7f1d1d}.k.no{opacity:.45}.k:active{transform:scale(.96)}
.k i{position:absolute;right:6px;top:4px;font-style:normal;font-size:11px;color:var(--m)}
.bar{padding:0 14px 10px;display:flex;gap:8px;flex-wrap:wrap}.bar button{background:#334155;color:var(--t);border:0;border-radius:10px;padding:8px 12px}
.bar button.on{background:#f59e0b;color:#111}
#msg{margin:0 14px 10px;padding:10px;border-radius:10px;background:#1e3a8a;display:none}
section{margin:14px;padding:12px;background:var(--c);border-radius:12px}section h3{margin:0 0 8px;font-size:16px}
section input{width:100%;padding:9px;margin:4px 0 8px;border-radius:8px;border:1px solid #475569;background:#0b1220;color:var(--t)}
section button{background:var(--a);border:0;border-radius:8px;padding:9px 14px;color:#052e16;font-weight:600}
small{color:var(--m)}
</style></head><body>
<header><b>📡 Remote WiFi</b><small id="ip"></small></header>
<div class="tabs" id="tabs"></div>
<div class="bar"><button id="mode">✋ Bấm để điều khiển</button><button id="add">+ Nút</button><button id="adddev">+ Thiết bị</button></div>
<div id="msg"></div>
<div class="grid" id="grid"></div>
<section><h3>Thử mã có sẵn (nút Nguồn)</h3><small>Dùng khi chưa có remote gốc. Nếu TV tắt/bật là đúng loại.</small><br><br>
<div class="bar" style="padding:0"><button onclick="preset('samsung')">Mã SAMSUNG</button><button onclick="preset('lg')">Mã LG (NEC)</button><button onclick="preset('sony')">Mã SONY</button><button onclick="preset('nec_generic')">NEC phổ thông</button></div></section>
<section><h3>Cài đặt WiFi nhà</h3><small id="wst"></small>
<input id="ssid" placeholder="Tên WiFi"><input id="pass" type="password" placeholder="Mật khẩu WiFi">
<button onclick="wifi()">Lưu & khởi động lại</button></section>
<section><h3>Nâng cao</h3><label><input type="checkbox" id="raw" style="width:auto"> Luôn lưu lệnh dạng thô (dùng cho điều hòa/thiết bị lạ)</label></section>
<script>
const DEF=[{dev:"TV",btns:[["pwr","⏻ Nguồn"],["src","Nguồn vào"],["mute","🔇"],["vup","Âm +"],["up","▲"],["chu","Kênh +"],["lf","◀"],["ok","OK"],["rt","▶"],["vdn","Âm −"],["dn","▼"],["chd","Kênh −"],["home","🏠"],["back","↩"],["menu","☰"]]}];
let L=DEF,cur=0,learned=new Set(),learnMode=false;
const $=s=>document.querySelector(s);
function msg(t,ms=2500){const m=$('#msg');m.textContent=t;m.style.display='block';clearTimeout(m._t);if(ms)m._t=setTimeout(()=>m.style.display='none',ms)}
async function api(p,o){const r=await fetch(p,o);if(!r.ok)throw new Error(await r.text());return r.headers.get('content-type')?.includes('json')?r.json():r.text()}
function key(d,b){return (d.slice(0,3).toLowerCase().replace(/[^a-z0-9]/g,'x')+'_'+b).slice(0,15)}
function render(){
 $('#tabs').innerHTML=L.map((d,i)=>`<button class="${i==cur?'on':''}" onclick="cur=${i};render()">${d.dev}</button>`).join('');
 const d=L[cur];if(!d)return;
 $('#grid').innerHTML=d.btns.map(([id,lb])=>{const k=key(d.dev,id);return `<button class="k ${id=='pwr'?'pwr':''} ${learned.has(k)?'':'no'}" onclick="press('${k}','${lb}')">${lb}<i>${learned.has(k)?'':'chưa học'}</i></button>`}).join('');
 $('#mode').textContent=learnMode?'🎓 Đang ở chế độ HỌC — bấm nút cần học':'✋ Bấm để điều khiển';$('#mode').className=learnMode?'on':'';
}
async function press(k,lb){
 if(learnMode||!learned.has(k)){return learn(k,lb)}
 try{await api('/api/send?id='+k,{method:'POST'});navigator.vibrate&&navigator.vibrate(30)}catch(e){msg('Lỗi: '+e.message)}
}
async function learn(k,lb){
 msg('Chĩa remote gốc vào mắt thu và bấm nút "'+lb+'" (10 giây)…',0);
 await api('/api/learn?id='+k,{method:'POST'});
 for(let i=0;i<24;i++){await new Promise(r=>setTimeout(r,500));const s=await api('/api/learn');
  if(s.status=='done'){learned.add(k);render();return msg('✅ Đã học "'+lb+'" ('+s.proto+')')}
  if(s.status=='timeout')return msg('⏱ Hết giờ, chưa nhận được tín hiệu. Thử lại gần hơn.')}
}
async function preset(p){try{await api('/api/preset?p='+p,{method:'POST'});msg('Đã phát mã '+p)}catch(e){msg(e.message)}}
async function save(){await api('/api/layout',{method:'POST',body:JSON.stringify(L)})}
$('#mode').onclick=()=>{learnMode=!learnMode;render()};
$('#add').onclick=async()=>{const lb=prompt('Tên nút mới (vd: Hẹn giờ):');if(!lb)return;const id=('b'+Date.now().toString(36)).slice(-8);L[cur].btns.push([id,lb]);await save();render();learn(key(L[cur].dev,id),lb)};
$('#adddev').onclick=async()=>{const n=prompt('Tên thiết bị (vd: Quạt, Đầu thu, Máy lạnh):');if(!n)return;L.push({dev:n.slice(0,20),btns:[["pwr","⏻ Nguồn"]]});cur=L.length-1;await save();render()};
$('#raw').onchange=e=>api('/api/raw?on='+(e.target.checked?1:0),{method:'POST'});
async function wifi(){await api('/api/wifi',{method:'POST',body:new URLSearchParams({ssid:$('#ssid').value,pass:$('#pass').value})});msg('Đã lưu, mạch khởi động lại. Kết nối lại vào WiFi nhà rồi mở http://tivi.local',0)}
(async()=>{const s=await api('/api/state');learned=new Set(s.learned);if(s.layout&&s.layout.length)L=s.layout;$('#raw').checked=s.raw;
 $('#ip').textContent=s.sta?('IP: '+s.ip):'Chế độ phát WiFi';$('#wst').textContent=s.sta?('Đã nối "'+s.ssid+'" — IP '+s.ip+' (hoặc http://tivi.local)'):'Chưa nối WiFi nhà';render()})();
</script></body></html>)HTML";

String jsonEscape(const String& s) { String o; for (char ch : s) { if (ch == '"' || ch == '\\') o += '\\'; o += ch; } return o; }

void handleState() {
  String learned = "[";
  nvs_iterator_t it = nullptr;
  esp_err_t e = nvs_entry_find("nvs", "codes", NVS_TYPE_BLOB, &it);
  bool first = true;
  while (e == ESP_OK) {
    nvs_entry_info_t info; nvs_entry_info(it, &info);
    if (!first) learned += ','; first = false;
    learned += '"'; learned += info.key; learned += '"';
    e = nvs_entry_next(&it);
  }
  nvs_release_iterator(it);
  learned += "]";
  String layout = cfg.getString("layout", "[]");
  bool sta = WiFi.status() == WL_CONNECTED;
  String j = "{\"sta\":" + String(sta ? "true" : "false") + ",\"ip\":\"" + (sta ? WiFi.localIP().toString() : WiFi.softAPIP().toString()) +
             "\",\"ssid\":\"" + jsonEscape(cfg.getString("ssid", "")) + "\",\"raw\":" + (forceRaw ? "true" : "false") +
             ",\"learned\":" + learned + ",\"layout\":" + layout + "}";
  server.send(200, "application/json", j);
}

void setupWeb() {
  server.on("/", []() { server.send_P(200, "text/html; charset=utf-8", PAGE); });
  server.on("/api/state", HTTP_GET, handleState);
  server.on("/api/send", []() {
    String id = server.arg("id");
    if (!validId(id)) return server.send(400, "text/plain", "id sai");
    if (!sendCode(id)) return server.send(404, "text/plain", "Nút này chưa học lệnh");
    server.send(200, "text/plain", "ok");
  });
  server.on("/api/preset", HTTP_POST, []() { server.send(sendPreset(server.arg("p")) ? 200 : 400, "text/plain", "ok"); });
  server.on("/api/learn", HTTP_POST, []() {
    String id = server.arg("id");
    if (!validId(id)) return server.send(400, "text/plain", "id sai");
    learnId = id; learnProto = ""; learnState = L_WAIT; learnStart = millis();
    irrecv.enableIRIn();
    digitalWrite(PIN_LED, HIGH);
    server.send(200, "text/plain", "ok");
  });
  server.on("/api/learn", HTTP_GET, []() {
    const char* st[] = {"idle", "waiting", "done", "timeout"};
    server.send(200, "application/json", String("{\"status\":\"") + st[learnState] + "\",\"id\":\"" + learnId + "\",\"proto\":\"" + jsonEscape(learnProto) + "\"}");
  });
  server.on("/api/forget", HTTP_POST, []() { codes.remove(server.arg("id").c_str()); server.send(200, "text/plain", "ok"); });
  server.on("/api/layout", HTTP_POST, []() {
    String body = server.arg("plain");
    if (body.length() > 3500 || !body.startsWith("[")) return server.send(400, "text/plain", "bố cục quá lớn");
    cfg.putString("layout", body); server.send(200, "text/plain", "ok");
  });
  server.on("/api/raw", HTTP_POST, []() { forceRaw = server.arg("on") == "1"; cfg.putBool("raw", forceRaw); server.send(200, "text/plain", "ok"); });
  server.on("/api/wifi", HTTP_POST, []() {
    cfg.putString("ssid", server.arg("ssid")); cfg.putString("pass", server.arg("pass"));
    server.send(200, "text/plain", "ok"); delay(500); ESP.restart();
  });
  server.onNotFound([]() { server.sendHeader("Location", "/"); server.send(302); });
  server.begin();
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_LED, OUTPUT);
  irsend.begin();
  codes.begin("codes", false);
  cfg.begin("cfg", false);
  forceRaw = cfg.getBool("raw", false);
  irrecv.setUnknownThreshold(12);

  WiFi.mode(WIFI_AP_STA);
  WiFi.softAP(AP_SSID, cfg.getString("appass", "machgon123").c_str());
  String ssid = cfg.getString("ssid", "");
  if (ssid.length()) {
    WiFi.begin(ssid.c_str(), cfg.getString("pass", "").c_str());
    uint32_t t = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - t < 15000) { delay(250); digitalWrite(PIN_LED, !digitalRead(PIN_LED)); }
    digitalWrite(PIN_LED, LOW);
  }
  if (WiFi.status() == WL_CONNECTED) Serial.printf("WiFi nha: %s  IP: %s\n", ssid.c_str(), WiFi.localIP().toString().c_str());
  Serial.printf("WiFi rieng: %s  IP: %s\n", AP_SSID, WiFi.softAPIP().toString().c_str());
  if (MDNS.begin(HOSTNAME)) MDNS.addService("http", "tcp", 80);
  setupWeb();
  blink(3);
}

void loop() {
  server.handleClient();
  if (learnState == L_WAIT) {
    if (irrecv.decode(&results)) {
      bool usable = !results.repeat && (results.decode_type != decode_type_t::UNKNOWN || results.rawlen > 12);
      if (usable && saveCode(learnId, results)) {
        learnProto = typeToString(results.decode_type, false);
        if (forceRaw || results.decode_type == decode_type_t::UNKNOWN) learnProto += " (thô)";
        learnState = L_DONE;
        irrecv.disableIRIn();
        digitalWrite(PIN_LED, LOW);
        blink(2, 120);
        Serial.printf("Da hoc %s: %s\n", learnId.c_str(), learnProto.c_str());
      } else {
        irrecv.resume();
      }
    } else if (millis() - learnStart > LEARN_MS) {
      learnState = L_TIMEOUT;
      irrecv.disableIRIn();
      digitalWrite(PIN_LED, LOW);
    }
  }
  // Mất WiFi nhà thì tự nối lại
  static uint32_t lastCheck = 0;
  if (millis() - lastCheck > 30000) {
    lastCheck = millis();
    if (cfg.getString("ssid", "").length() && WiFi.status() != WL_CONNECTED) WiFi.reconnect();
  }
}

🔌 Cắm thử trên breadboard

Cắm nháp trên board cắm trước cho chắc, chạy đúng rồi hãy làm mạch in. Sơ đồ này do máy tự xếp từ chính netlist ở trên và đã đối chiếu lại: hai chân cùng một cột (cùng nửa board) là thông nhau, không có net nào bị chập.

+−+−55101015152020252530303535JIHGFEDCBAU1 · ESP32 DevKit V1 (30 chân)274GNDVIN3V3Q1 S8050R1 1kR2 27D1D2U2R3 100C1 10u+
  • Mỗi cột 5 lỗ trong cùng một nửa board thông nhau; hai nửa cách nhau bởi rãnh giữa.
  • Hai đường kẻ dọc mép board là đường nguồn: hàng + (vạch đỏ) và hàng − (vạch xanh). Nhớ bắc dây nối đường nguồn trên với đường nguồn dưới nếu dùng cả hai nửa.
  • Màu dây trên hình trùng với màu trong bảng nối dây ở mục Sơ đồ, cắm theo màu cho dễ dò.

🧲 Mạch in để ủi (PCB 1 mặt)

Bản mạch in một mặt đồng, tự xếp linh kiện và tự đi dây, đã kiểm quy tắc khoảng cách và đối chiếu đúng 100% với sơ đồ nguyên lý ở trên. In tỉ lệ 1:1 rồi ủi lên tấm đồng.

Kích thước board55,9×55,9 mm48 lỗ khoan · mũi 0,8 / 0,9 / 1mm
Đi dây 1 mặt13 đoạn · 334,6 mmKhông cần cầu nhảy nào
Kiểm tra bản mạchĐạt DRC · khớp 100% netlistĐường ≥0,8mm, khe ≥0,5mm; mọi net đối chiếu lại từ hình học thật
Mặt đồng — đã lật gương sẵn để in rồi ủi úp xuống tấm đồng
Mặt linh kiện — vị trí, chiều cắm và tên từng chân
U1Q1R1R2D1D2U2R3C1EN363934353233252627141213GNDVIN232213211918517164215GND23V3
Hướng dẫn ủi – ăn mòn – khoan – hàn (đầy đủ)

Hướng dẫn làm mạch in bằng phương pháp ủi

Mạch: Điều khiển TV bằng điện thoại qua WiFi (học lệnh từ remote cũ)

Kích thước board: 55,9×55,9 mm · 48 lỗ khoan (mũi 0,8, 0,9 và 1 mm) · 13 đoạn mạch, tổng 334,6 mm · 0 cầu nhảy.

Quy tắc đã kiểm: đường mạch rộng 0,8mm (nguồn và GND 1mm), khe cách tối thiểu 0,5mm. Mạch in đã được máy đối chiếu đúng 100% với sơ đồ nguyên lý.

1. In đúng tỉ lệ 1:1

  • In trang "MẶT ĐỒNG" bằng máy in LASER, đặt tỉ lệ 100%, KHÔNG chọn "Fit to page" hay "Scale to fit".
  • Dùng thước trên bản in đo lại đoạn 100mm: phải đúng 100mm mới dùng được.
  • In trên giấy in ảnh bóng, giấy decal bóc, hoặc giấy tạp chí láng. Mực càng dày càng tốt.
  • Trang "MẶT ĐỒNG" đã được LẬT GƯƠNG sẵn: úp mặt có mực vào tấm đồng là đúng chiều.

2. Chuẩn bị tấm đồng

  • Cắt phíp đồng (board đồng một mặt) lớn hơn kích thước mạch mỗi bề khoảng 5mm.
  • Chà mặt đồng bằng giấy nhám mịn (P800–P1200) rồi rửa sạch, lau khô. Mặt đồng phải sáng, không vết dầu mỡ, không chạm tay vào.

3. Ủi mực sang đồng

  • Úp mặt có mực vào tấm đồng, dùng băng keo giấy dán cố định một cạnh.
  • Bàn ủi để mức nóng nhất (cotton/linen), KHÔNG dùng chế độ phun hơi nước.
  • Ủi đè mạnh và đều 3–5 phút, tập trung từng vùng nhỏ, chú ý các góc và mép.
  • Để nguội hẳn rồi ngâm cả tấm vào nước ấm 5–10 phút, bóc giấy thật nhẹ. Giấy decal thì bóc khi còn ấm.
  • Soi kỹ: chỗ nào mực bị thiếu hoặc đứt đường thì tô lại bằng bút lông dầu (bút CD) rồi để khô.

4. Ăn mòn (etching)

  • Pha dung dịch sắt(III) clorua FeCl3 theo hướng dẫn trên bao bì, hoặc dung dịch oxy già + muối ăn + a-xít; dùng hộp nhựa, KHÔNG dùng đồ kim loại.
  • Nhiệt độ dung dịch khoảng 40–50°C thì ăn nhanh hơn. Lắc nhẹ hộp cho đều, thường 10–25 phút là xong.
  • An toàn: đeo găng tay cao su và kính bảo hộ, làm ở nơi thoáng. Dung dịch ăn mòn làm hỏng quần áo, bồn rửa và gây bỏng rát da.
  • Xong thì rửa board bằng nước sạch, lau khô, dùng xăng thơm hoặc axeton lau sạch lớp mực.
  • Dung dịch đã dùng KHÔNG đổ xuống bồn rửa hay cống; đựng vào can kín và bỏ đúng nơi thu gom chất thải.

5. Khoan lỗ

  • Đối chiếu trang "BẢN VẼ LỖ KHOAN" để biết lỗ nào dùng mũi nào.
  • Dùng mũi khoan 0,8mm, 0,9mm và 1mm, khoan từ MẶT ĐỒNG vào để mũi tự định tâm giữa pad.
  • Khoan tốc độ cao, đẩy nhẹ; mũi nhỏ rất dễ gãy. Khoan xong chà nhẹ bavia hai mặt.

6. Hàn linh kiện

  • Linh kiện cắm ở MẶT KHÔNG CÓ ĐỒNG, hàn ở mặt đồng. Theo trang "MẶT LINH KIỆN" để đặt đúng vị trí và đúng chiều.
  • Hàn linh kiện thấp trước (điện trở, đi-ốt), cao sau (tụ, hàng chân, board vi điều khiển).
  • Chú ý chiều: LED chân dài là (+); đi-ốt có vạch trắng là K; tụ hoá có vạch là (−); transistor theo mặt chữ như nét vẽ đỏ.
  • Trước khi cấp điện: đo thông mạch giữa chân nguồn và GND — KHÔNG được kêu (không được chập).

Lưu ý với board vi điều khiển

  • Board nhái có thể khác thứ tự chân. Đối chiếu tên chân in cạnh từng lỗ trên trang "MẶT LINH KIỆN" với chữ in trên board thật TRƯỚC KHI ăn mòn. Nếu khác, đừng hàn mà báo lại để sửa bản vẽ.
Bản vẽ lỗ khoan
BẢN VẼ LỖ KHOAN (nhìn từ mặt linh kiện)mũi khoan 0,8mm — 9 lỗmũi khoan 0,9mm — 9 lỗmũi khoan 1mm — 30 lỗ

Đo kiểm bằng đồng hồ vạn năng

Giữa chân VCC và GND của mắt thu (khi đã cắm USB)3,2 – 3,3V
Giữa VIN và GND4,6 – 5,1V
Soi LED hồng ngoại bằng camera điện thoại (camera trước) khi bấm nútThấy LED nháy tím/trắng mờ
Giữa chân C và E của S8050 lúc nghỉkhoảng 2,2V (LED tắt)

Sửa lỗi thường gặp

Bấm mà TV không phản ứng

Soi LED bằng camera điện thoại; nếu không nháy kiểm tra chiều S8050 (E-B-C) và chiều LED. Nếu nháy mà TV không nhận: học lại lệnh, đứng gần hơn, hoặc dùng mục "Mã có sẵn".

Học lệnh báo hết giờ

Kiểm tra 3 chân mắt thu (OUT-GND-VCC), đưa remote gần hơn và tránh đèn huỳnh quang/ánh nắng chiếu thẳng.

Không thấy mạng WiFi MachGon-TV

Mở Serial Monitor tốc độ 115200 xem log; cắm lại USB; thử cáp USB khác (nhiều cáp chỉ sạc, không có dây dữ liệu).

Một số máy lạnh không học được

Lệnh điều hòa rất dài, mạch lưu ở dạng thô nên thường vẫn dùng được; nếu không được, chọn "Lưu dạng thô" trong trang Cài đặt rồi học lại.

⚠️ An toàn

  • Mạch chỉ dùng điện 5V từ USB, an toàn khi chạm tay.
  • Không mở TV để hàn vào bên trong.