Đ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.
✅ Đã kiểm thử
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 ✓
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.
| # | Dây | Nối các điểm |
|---|---|---|
| 1 | đỏ 5V | U1 VIN ⟷ R2 1 |
| 2 | cam 3V3 | U1 3V3 ⟷ R3 1 |
| 3 | đen GND | U1 GND ⟷ Q1 E (phát) ⟷ U2 GND · chân 2 GND ⟷ C1 - |
| 4 | vàng IR_BASE_DRV | U1 GPIO4 (D4) ⟷ R1 1 |
| 5 | xanh lá IR_BASE | R1 2 ⟷ Q1 B (gốc) |
| 6 | xanh dương IR_A1 | R2 2 ⟷ D1 A · chân dài (+) |
| 7 | tím IR_MID | D1 K · chân ngắn (−) ⟷ D2 A · chân dài (+) |
| 8 | xanh ngọc IR_COL | D2 K · chân ngắn (−) ⟷ Q1 C (góp) |
| 9 | nâu RX_VCC | R3 2 ⟷ U2 VCC · chân 3 VCC ⟷ C1 + |
| 10 | hồng RX_OUT | U2 OUT · chân 1 OUT ⟷ U1 GPIO27 (D27) |
Danh sách linh kiện
| Ký hiệu | Linh kiện | Giá tham khảo | |
|---|---|---|---|
| U1 | ESP32 DevKit V1 30 chân (ESP32-WROOM-32, cổng USB-C hoặc Micro USB) | 110.000đ | Tìm mua |
| Q1 | Transistor S8050 (TO-92) — thay được bằng 2N2222 | 500đ | 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 |
| D1 | LED phát hồng ngoại 940nm 5mm (vỏ trong suốt) | 1.000đ | Tìm mua |
| D2 | LED phát hồng ngoại 940nm 5mm (vỏ trong suốt) | 1.000đ | Tìm mua |
| U2 | Mắ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 |
| C1 | Tụ 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();
}
}
Đ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à GND | 4,6 – 5,1V |
| Soi LED hồng ngoại bằng camera điện thoại (camera trước) khi bấm nút | Thấ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.