Your Smart Home Shouldn't Die When the Internet Does: Building a Local-First IoT Stack with MQTT, Zigbee2MQTT and Home Assistant
Tối thứ Sáu tuần trước, cáp quang nhà mình đứt (lại cá mập, hoặc lại thi công đường). Mất mạng 6 tiếng thì chuyện thường. Điều làm mình bực là cái công tắc thông minh ở phòng khách cũng "chết" theo. Đèn nằm cách router đúng 5 mét, nhưng muốn bật nó thì lệnh phải chạy sang một server ở Singapore rồi quay về. Trên Hacker News đang có thread "Smart home devices should work without the internet" hơn 600 điểm, và đọc comment thì thấy ai cũng từng dính cảnh này: vendor đóng cửa server, đổi API, bắt trả subscription, hoặc đơn giản là cloud bị down. Bài này chia sẻ cách mình dựng một hệ smart home local-first: mọi automation chạy trong LAN, Internet chỉ là tùy chọn. Stack gồm Mosquitto 2.0, Zigbee2MQTT 2.x, Home Assistant 2026.x và một script Python nhỏ. Tất cả chạy được trên Raspberry Pi 4 hoặc một con mini PC N100 cũ. Vì sao cloud-first là thiết kế tồi cho thiết bị vật lý Phần lớn thiết bị Wi-Fi giá rẻ (Tuya, các hãng white-label bán trên Shopee/Lazada) đi theo mô hình: thiết bị → cloud vendor → app điện thoại. Mô hình này có 3 vấn đề: Latency và availability: Bật đèn mất 300–800ms là khi mọi thứ ổn, còn mất mạng thì không bật được. Lifecycle phụ thuộc vendor: Vendor phá sản hay khai tử sản phẩm là thiết bị thành cục gạch. Chuyện này đã xảy ra nhiều lần rồi. Privacy & security: Thiết bị trong nhà bạn giữ kết nối outbound thường trực ra Internet, chạy firmware bạn không kiểm soát được. Kiến trúc local-first đảo ngược chuyện đó: một message broker trong LAN làm trung tâm, cloud (nếu cần) chỉ là một client phụ. graph LR Z[Zigbee sensors & switches] -->|Zigbee radio| ZD[USB Coordinator] ZD --> Z2M[Zigbee2MQTT] T[Wi-Fi plugs - Tasmota/ESPHome] -->|MQTT| B[Mosquitto broker] Z2M -->|MQTT| B B HA[Home Assistant] B PY[Custom Python scripts] HA -.->|optional VPN| P[Phone khi ra ngoai] Điểm quan trọng: mũi tên đi ra Internet là nét đứt, chỉ để bạn điều khiển từ xa qua VPN (WireGuard/Tailscale). Thiếu nó thì mọi thứ trong nhà vẫn chạy. Dựng stack bằng Docker Compose Phần cứng tối thiểu: một máy Linux luôn bật và một USB Zigbee coordinator (Sonoff ZBDongle-E hoặc ZBDongle-P, khoảng 400–500k). Mình ưu tiên Zigbee hơn Wi-Fi vì thiết bị Zigbee không có IP, nên tự nhiên không gọi về cloud được. # docker-compose.yml services: mosquitto: image: eclipse-mosquitto:2.0 restart: unless-stopped ports: - "1883:1883" volumes: - ./mosquitto/config:/mosquitto/config - ./mosquitto/data:/mosquitto/data zigbee2mqtt: image: koenkk/zigbee2mqtt:2.6.0 restart: unless-stopped depends_on: [mosquitto] volumes: - ./z2m:/app/data - /run/udev:/run/udev:ro devices: - /dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus-if00-port0:/dev/ttyACM0 environment: - TZ=Asia/Ho_Chi_Minh homeassistant: image: ghcr.io/home-assistant/home-assistant:stable restart: unless-stopped network_mode: host volumes: - ./ha:/config - /etc/localtime:/etc/localtime:ro Vài lưu ý mình rút ra sau khi tự vấp: Luôn dùng /dev/serial/by-id/..., đừng dùng /dev/ttyUSB0. Cắm thêm USB khác là số thứ tự đổi ngay. Mosquitto 2.x mặc định từ chối kết nối anonymous. Tạo user bằng docker compose exec mosquitto mosquitto_passwd -c /mosquitto/config/passwd ha, rồi thêm listener 1883 và password_file /mosquitto/config/passwd vào mosquitto.conf. Pin version cho Zigbee2MQTT. Bản major 2.0 có breaking change về config, đừng để latest tự update rồi sáng ra cả nhà không bật được đèn. Với thiết bị Wi-Fi đã lỡ mua, kiểm tra trên trang device templates của Tasmota hoặc ESPHome xem chip có phải ESP8266/ESP32 không. Nếu phải, flash firmware mở qua serial hoặc OTA rồi trỏ MQTT về broker nội bộ. Với các chip mới của Tuya (BK7231, dòng beken) thì có thể thử LibreTiny/OpenBeken. Viết automation local bằng Python + MQTT Home Assistant đủ dùng cho 90% nhu cầu. Nhưng với logic phức tạp, mình thích viết service Python riêng nói chuyện thẳng với broker: dễ test, dễ version control, và không phụ thuộc UI. Ví dụ: cảm biến cửa mở sau 22h thì bật đèn hành lang 3 phút, đồng thời log lại. # door_watch.py — pip install paho-mqtt==2.1.0 import json, threading from datetime import datetime import paho.mqtt.client as mqtt BROKER = '192.168.1.10' DOOR_TOPIC = 'zigbee2mqtt/front_door' LIGHT_SET = 'zigbee2mqtt/hallway_light/set' timer = None def turn_off(client): client.publish(LIGHT_SET, json.dumps({'state': 'OFF'}), qos=1) def on_message(client, userdata, msg): global timer payload = json.loads(msg.payload) # Aqara/Sonoff door sensor: contact=False nghia la cua dang mo if payload.get('contact') is False and datetime.now().hour >= 22: client.publish(LIGHT_SET, json.dumps({'state': 'ON', 'brightness': 120}), qos=1) print(f'[{datetime.now():%H:%M:%S}] door opened -> light on') if timer: timer.cancel() timer = threading.Timer(180, turn_off, args=[client]) timer.start() def on_connect(client, userdata, flags, reason_code, properties): client.subscribe(DOOR_TOPIC, qos=1) client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id='door-watch') client.username_pw_set('automation', 'doi-mat-khau-di') client.on_connect = on_connect client.on_message = on_message client.reconnect_delay_set(min_delay=1, max_delay=30) client.connect(BROKER, 1883, keepalive=60) client.loop_forever() Điểm cần để ý: paho-mqtt 2.x bắt buộc truyền CallbackAPIVersion. Code cũ copy từ StackOverflow năm 2021 sẽ báo lỗi ngay, và signature của on_connect cũng đổi (thêm properties). Subscribe trong on_connect để khi broker restart, client reconnect xong sẽ tự subscribe lại. Luồng xử lý khi mất Internet: sequenceDiagram participant S as Door sensor participant Z as Zigbee2MQTT participant B as Mosquitto participant P as door_watch.py participant L as Hallway light S->>Z: contact=false (Zigbee) Z->>B: publish zigbee2mqtt/front_door B->>P: deliver message P->>B: publish hallway_light/set ON B->>Z: forward Z->>L: Zigbee command Note over S,L: Toan bo < 100ms, khong can Internet Chặn thiết bị gọi về nhà bằng VLAN + firewall Local-first chưa đủ, cần ép thiết bị phải local. Mình tách thiết bị IoT Wi-Fi sang VLAN riêng (ví dụ 192.168.20.0/24), chỉ cho chúng nói chuyện với broker, cấm ra Internet. Trên router OpenWrt 24.x hoặc một con Linux gateway dùng nftables: # /etc/nftables.d/iot.nft table inet iot_guard { chain forward { type filter hook forward priority 0; policy accept; # Cho phep IoT noi chuyen voi MQTT broker ip saddr 192.168.20.0/24 ip daddr 192.168.1.10 tcp dport 1883 accept # Cho phep NTP noi bo (thiet bi can gio dung) ip saddr 192.168.20.0/24 ip daddr 192.168.1.1 udp dport 123 accept # Chan tat ca phan con lai, log de biet ai dang goi ve nha ip saddr 192.168.20.0/24 log prefix "IOT-BLOCK " limit rate 10/minute drop } } # Ap dung va theo doi nft -f /etc/nftables.d/iot.nft logread -f | grep IOT-BLOCK # OpenWrt # journalctl -kf | grep IOT-BLOCK # Linux thuong Để log chạy vài ngày, bạn sẽ bất ngờ với số domain mà một cái ổ cắm 150k cố gọi tới. Nhớ cung cấp NTP nội bộ: nhiều firmware mất giờ là lịch hẹn giờ hỏng theo. Nếu chưa sẵn sàng làm VLAN, ít nhất hãy dùng DNS sinkhole (AdGuard Home hoặc Pi-hole) để chặn domain cloud của vendor. Cách này không triệt để bằng firewall vì có firmware hardcode IP, nhưng làm trong 10 phút là xong. Kết luận Mạng ở Việt Nam đứt cáp quang biển gần như năm nào cũng có, nên làm smart home local-first là chuyện thực tế chứ không phải sở thích của dân mê self-host. Checklist để bắt đầu: Mua thiết bị có tính đến lối thoát: ưu tiên Zigbee, Thread/Matter (Matter vốn chạy local), hoặc Wi-Fi dùng chip ESP flash được. Trước khi xuống tiền, tìm tên model kèm chữ "Zigbee2MQTT" hoặc "Tasmota". Đặt MQTT broker làm trung tâm: Mosquitto 2.0 bật auth, pin version mọi container. Logic quan trọng phải chạy local: đèn, khóa cửa, cảm biến khói, không được phụ thuộc cloud. Cô lập IoT bằng VLAN + nftables, chặn mặc định, log để audit. Remote access qua VPN (WireGuard/Tailscale), không mở port 8123 ra Internet. Test bằng cách rút dây WAN: rút cáp quang ra 30 phút xem nhà còn "thông minh" không. Thiết bị nào chết theo thì ghi vào danh sách cần thay. Một nguyên tắc đáng nhớ: thiết bị nằm trong nhà bạn thì quyền điều khiển cũng nên nằm trong nhà bạn.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to