Thực Hiện Trạm Thời Tiết Cá Nhân Hóa Sử Dụng Adafruit PyPortal Và CircuitPython

Giới Thiệu Kiến Trúc Dự Án

Hành trình phát triển ứng dụng IoT thường dừng lại ở khâu tích hợp phần cứng phức tạp và xử lý mạng cấp thấp. Giải pháp thay thế là sử dụng bo mạch Adafruit PyPortal kết hợp cùng hệ điều hành nhúng CircuitPython, cho phép dựng nhanh một thiết bị theo dõi khí tượng để bàn chỉ trong vài giờ. Bo mạch này tích hợp sẵn vi điều khiển lõi ARM Cortex-M4 (ATSAMD51), module truyền thông Wi-Fi BLE ESP32, màn hình điện dung 3.2 inch độ phân giải 320×240 pixel, cùng mạch quản lý nguồn tích hợp. Thay vì phải ghép nối từng linh kiện rời, nhà phát triển chỉ cần truyền tập tin qua cổng USB để nạp mã lệnh ngay lập tức.

Mô hình ứng dụng này tuân thủ mô hình ba lớp chuẩn của hệ thống nhúng thông minh: tầng cảm biến/hiển thị (màn hình NeoPixel & cảm biến nhiệt ADT7410 trên board), tầng truyền dẫn (kết nối TCP/IP qua Wi-Fi), và tầng ứng dụng đám mây (tiếp nhận dữ liệu JSON từ máy chủ meteorology). Quy trình vận hành bao gồm việc xác thực kết nối mạng, gửi yêu cầu RESTful đến API thời tiết, giải mã cấu trúc dữ liệu trả về, và ánh xạ trực tiếp lên vùng nhớ hiển thịGRAM thông qua thư viện `displayio`. Đây là lộ trình tối ưu để nắm vững luồng dữ liệu IoT mà không cần đào sâu vào driver C hay toolchain biên dịch nặng ký.

Tích Hợp Phần Sụn Và Quản Lý Gói Thư Viện

Cấu Hình Bootloader UF2

Mỗi bo mạch PyPortal khi xuất xưởng đều chạy firmware nền. Để kích hoạt môi trường CircuitPython, người dùng cần tải file `.uf2` tương thích từ kho lưu trữ chính thức. Sau khi gắn cáp USB 2.0 chuẩn dữ liệu vào máy tính, nhấn đúp liên tục nút Reset nằm ngay cạnh chân cắm jack âm thanh. Đèn LED RGB onboard sẽ chuyển sang màu xanh lá, báo hiệu bootloader `PORTALBOOT` đã được kích hoạt. Kéo thả file `.uf2` vào ổ đĩa ảo vừa hiện ra. Thiết bị sẽ tự restart và mount ổ `CIRCUITPY` với hệ thống file FAT32 sẵn sàng ghi chép.

Triển Khai Module Bổ Sung

Hệ sinh thái CircuitPython tách rời các hàm năng suất cao thành các gói `.mpy` đã được biên dịchahead-of-time. Việc sao chép toàn bộ thư mục `lib` từ bundle phát hành sẽ nhanh chóng làm đầy bộ nhớ flash (~8MB) của bo mạch. Chiến lược tối ưu là chỉ trích xuất các module phục vụ trực tiếp cho luồng nghiệp vụ:

  • adafruit_pyportal.mpy – Abstraction layer điều khiển mạng và giao diện đồ họa nền tảng
  • adafruit_esp32spi.mpy – Driver SPI giao tiếp với chip Wi-Fi ngoại vi
  • adafruit_requests.mpy / adafruit_connection_manager.mpy – Xử lý vòng đời socket TCP và HTTP transaction
  • adafruit_display_text.mpy / adafruit_bitmap_font.mpy – Renderer chữ vectơ và điểm ảnh
  • adafruit_imageload.mpy / neopixel.mpy – Hỗ trợ load bitmap và điều khiển strip LED trạng thái

Nếu quá trình thực thi ném exception ModuleNotFoundError, hãy kiểm tra lại đường dẫn tương đối so với tệp main.py hoặc code.py. Ưu tiên đặt module vào thư mục con /lib/ nằm ngang hàng với thư mục gốc của ổ đĩa để đảm bảo trình thông dịch tìm thấy namespace đúng.

Khai Báo Biến Môi Trường An Toàn

Việc hardcode credential vào source code vi phạm nguyên tắc bảo mật cơ bản. Từ phiên bản 8.x trở đi, CircuitPython hỗ trợ file định dạng TOML để chứa cấu hình nhạy cảm. Tạo tệp mới tên secrets.toml tại thư mục root của CIRCUITPY và khai báo các khóa xác thực:

# Mạng không dây cục bộ
WIFI_SSID = "Ten_Mang_Cua_Ban"
WIFI_PASSWORD = "Mat_Khau_WiFi"

# Khóa truy cập dịch vụ API thời tiết
WEATHER_API_TOKEN = "Chuoi_Key_OpenWeather"

# Thông tin đồng bộ RTC từ NTP server
ADAFRUIT_IO_USER = "Tai_Khoan_Adafruit"
ADAFRUIT_IO_KEY = "Active_Key_Dich_Vu"

Trong kịch bản Python, trích xuất giá trị bằng phương thức os.getenv(). Kiểm tra null trước khi khởi tạo đối tượng mạng để tránh crash kernel:

import os

def load_env_config():
    creds = {
        "wifi_id": os.getenv("WIFI_SSID"),
        "wifi_sec": os.getenv("WIFI_PASSWORD"),
        "token": os.getenv("WEATHER_API_TOKEN"),
        "aio_user": os.getenv("ADAFRUIT_IO_USER"),
        "aio_key": os.getenv("ADAFRUIT_IO_KEY")
    }
    missing_keys = [k for k, v in creds.items() if not v]
    if missing_keys:
        raise EnvironmentError(f"Thieu cau hinh trong secrets.toml: {missing_keys}")
    return creds

Để an toàn khi version control, thêm secrets.toml vào danh sách ignore nhằm ngăn rò rỉ token ra repository công khai.

Logic Điều Khiển Chính Và Render Giao Diện

Khuôn Khổ Vòng Lặp Trạng Thái

Dưới đây là cấu trúc mã nguồn chính sau khi tái cấu trúc, tập trung vào việc đóng gói cấu hình, quản lý timer độc lập và xử lý ngoại lệ mạng:

import sys
import time
import board
from os import getenv
from adafruit_pyportal import PyPortal

# Tinh chon tham so tinh nang
TARGET_LOCATION = "Da_Nang, VN"
CHECK_INTERVAL_SEC = 600   # Cap nhat thoi tiet moi 10 phut
TIME_SYNC_HOURS = 4        # Dong bo thoi gian network
UNIT_SYSTEM = "metric"     # Do C hoac Fahrenheit

# Xu ly nguon cau hinh de dong
config = {
    "network_ssid": getenv("WIFI_SSID"),
    "network_pass": getenv("WIFI_PASSWORD"),
    "api_key": getenv("WEATHER_API_TOKEN"),
    "timezone_offset": getenv("TIMEZONE", "UTC+7")
}

if not all(config.values()):
    raise RuntimeError("Khong tim thay thong tin tai secrets.toml")

# Xay dung endpoint RESTful dong din
base_api = "https://api.openweathermap.org/data/2.5/weather"
query_params = f"{base_api}?q={TARGET_LOCATION}&appid={config['api_key']}&units={UNIT_SYSTEM}&lang=vi"

# Khoi tao doi tuong co ban dieu khien man hinh va mang
hw_device = PyPortal(
    url=query_params,
    json_path=[],
    status_neopixel=board.NEOPIXEL,
    default_bg=0x0A0A0A
)

# Gan nhom graph vao root display group
ui_renderer = hw_device.setup_ui(celsius=True, twelve_hour=False)

start_tick = time.monotonic()
last_net_time = 0.0
last_weather_pull = 0.0

while True:
    current_cycle = time.monotonic()
    
    # Su ki bat dau vong lap chinh
    hw_device.status_pixel.fill(0x00FF00)
    
    try:
        # Bat buoc dong bo thoi gian thuat phan chia
        if (current_cycle - last_net_time) > TIME_SYNC_HOURS * 3600:
            print("[INFO] Dang lay gio tu cloud NTP...")
            hw_device.get_local_time()
            last_net_time = current_cycle
    except RuntimeError:
        print("[WARN] Mat ket noi day du lieu thoi gian.")

    try:
        # Lay va giai ma payload JSON
        if (current_cycle - last_weather_pull) > CHECK_INTERVAL_SEC:
            payload = hw_device.fetch()
            if isinstance(payload, dict):
                ui_renderer.process_weather_data(payload)
                last_weather_pull = current_cycle
                print("[OK] Da cap nhat trang thai pho thoi.")
    except Exception as conn_err:
        print(f"[ERR] Thuat hai api loi: {conn_err}")
        hw_device.status_pixel.fill(0xFF0000)

    # Tiep tuc refresh clock local de cham dung hon 1s
    ui_renderer.refresh_clock_display()
    time.sleep(1)

Lớp Đồ Họa Nhúng

Theo dõi dữ liệu JSON đòi hỏi ánh xạ key-value vào widget riêng biệt. Mô-đun đồ họa kế thừa displayio.Group để quản lý z-order và event propagation:

import displayio
from adafruit_display_text import label
from adafruit_bitmap_font import bitmap_font

class WeatherDisplayLayer(displayio.Group):
    def __init__(self, parent_group, force_celsius=True):
        super().__init__()
        self.parent = parent_group
        self.is_celsius = force_celsius
        
        # Tai font lenh tu media storage
        header_font = bitmap_font.load_font("/media/fonts/NotoSans-Bold-28.bdf")
        body_font = bitmap_font.load_font("/media/fonts/NotoSans-Regular-16.bdf")
        
        self.lbl_city = label.Label(header_font, color=0xFFFFFF)
        self.lbl_temp = label.Label(header_font, color=0x00FFFF)
        self.lbl_desc = label.Label(body_font, color=0xAAAAAA)
        self.lbl_humid = label.Label(body_font, color=0xCCCCCC)
        self.lbl_clock = label.Label(body_font, color=0xF0F0F0)
        
        # Dinh vi spatial tren canvas 320x240
        self.lbl_city.x, self.lbl_city.y = 10, 15
        self.lbl_temp.x, self.lbl_temp.y = 15, 55
        self.lbl_desc.x, self.lbl_desc.y = 15, 115
        self.lbl_humid.x, self.lbl_humid.y = 15, 150
        self.lbl_clock.x, self.lbl_clock.y = 280, 200
        
        # Them vao cay nut graph
        for widget in [self.lbl_city, self.lbl_temp, self.lbl_desc, self.lbl_humid, self.lbl_clock]:
            self.append(widget)
            self.parent.append(self)

    def process_weather_data(self, raw_json):
        city_name = raw_json.get("name", "Unknown")
        temp_val = raw_json["main"]["temp"]
        humidity_pct = raw_json["main"]["humidity"]
        desc_text = raw_json["weather"][0].get("description", "N/A")
        icon_ref = raw_json["weather"][0]["icon"]
        
        unit_sym = "°C" if self.is_celsius else "°F"
        
        self.lbl_city.text = city_name.upper()
        self.lbl_temp.text = f"{temp_val:.1f}{unit_sym}"
        self.lbl_desc.text = desc_text.title()
        self.lbl_humid.text = f"Do Am: {humidity_pct}%"
        self._load_dynamic_icon(icon_ref)

    def _load_dynamic_icon(self, asset_code):
        path = f"/icons/{asset_code}.bmp"
        try:
            img_bm = displayio.OnDiskBitmap(path)
            sprite = displayio.TileGrid(img_bm, pixel_shader=img_bm.pixel_shader)
            sprite.x, sprite.y = 180, 60
            # Xoa sprite cu neu ton tai de tranh leak memory
            if len(self) > 1:
                self.pop()
            self.append(sprite)
        except FileNotFoundError:
            print(f"[WARN] Mat file do hoa: {path}")

    def refresh_clock_display(self):
        t_struct = time.localtime()
        hour, minute = t_struct.tm_hour, t_struct.tm_min
        time_fmt = f"{hour:02d}:{minute:02d}"
        self.lbl_clock.text = time_fmt

Kiến trúc này tách biệt hoàn toàn logic mạng và rendering view. Các đối tượng label được cập nhật trực tiếp thuộc tính .text mà không cần xóa bỏ canvas cũ, tận dụng cơ chế double buffering ngầm của displayio.

Quy Trình Xác Thực Và Sửa Lỗi Runtime

Sau khi ghi đè xong các tệp code.py, secrets.toml và thư mục phụ trợ, nhấn vật lý nút Reset. LED trạng thái sẽ blink xanh dương trong lúc handshaking SSL và DHCP. Nếu đạt, đèn chuyển sang xanh lá cố định, màn hình kích hoạt frame nền đen và tiến trình vòng lặp bắt đầu.

Trường hợp treo vô hạn hoặc reset liên tục, kết nối cổng COM ảo thông qua terminal như PuTTY hoặc CoolTerm. Set baudrate 115200, parity None, 1 stop bit. Dữ liệu stdout từ print() sẽ hiển thị log traceback chi tiết. Bảng dưới đây liệt kê các symptom thường gặp:

Dấu Hiệu Bất ThườngNguyên Nhân Tiềm ẨnHướng Khắc Phục
LED đỏ liên tụcAssertion fail hoặc segfaultKiểm tra cú pháp thụt lề, xác nhận thiếu dependency trong /lib/
Bluetooth/WiFi blinking xanh, không vào vòng lặpSai passphrase hoặc router chặn MACXác nhận lại WIFI_PASSWORD (phân biệt hoa/thường), tắt lọc địa chỉ MAC tạm thời
Lỗi cod: 401 Invalid API KeyToken hết hạn hoặc format saiLogin dashboard nhà cung cấp service để regnew key, đảm bảo không có khoảng trắng thừa trong secrets.toml
Màn hình đen giữ nguyên Loading...Timeout fetch hoặc timeout DNSĐảo URL query_params ra browser xem phản hồi JSON thực tế. Thay đổi DNS server về 8.8.8.8
Kim giờ chạy sai lệch nhiều giờKhông apply timezone offsetCircuitPython mặc định UTC. Thêm offset giây tương ứng vào hàm get_local_time() hoặc chỉnh lại struct thời gian sau sync

Khi triển khai lâu dài, cân nhắc bật cơ chế ngủ đông (power.save_current) hoặc tắt backlight (board.DISPLAY.brightness = 0.0) khi không phát hiện touch input, giúp kéo dài tuổi thọ pin nếu chuyển sang cấu hình di động. Ngoài ra, có thể mở rộng scope bằng cách hook vào webhook MQTT để push cảnh báo sét đánh hoặc mưa lớn xuống smartphone.

Thẻ: Adafruit PyPortal CircuitPython ESP32 SPI Networking OpenWeatherMap REST API Embedded Graphics Library

Đăng vào ngày 23 tháng 9 lúc 22:26