Seeed Xiao Esp32-S3 && MicroPython && Expansion Board && 電視遙控器(Nec協議)

需要的材料

認識 Seeeduino XIAO Expansion Board擴充板

在開始撰寫程式之前,先來認識這塊擴充板。

操作示意圖

01.jpeg

正面圖

Seeeduino XIAO Expansion Board擴充板正面

背面圖

Seeeduino XIAO Expansion Board擴充板背面

腳位圖

Seeeduino XIAO Expansion Board擴充板腳位圖
圖片來源: Seeed Studio 官方文件

接線圖

02.jpeg

開啟 Thonny 並上傳或新增範例程式,總共有七個PY檔。

IR_Codes.py


# ir_codes.py
# 專門放「紅外線資料」

# 電源鍵
NEC_TV_POWER = [                        # 定義一組 NEC 協議的紅外線脈衝資料                    
    8860, 4465,                               # Header:9ms mark + 4.5ms space
    619, 1664, 609, 555, 581, 557, 579, 1665,
    608, 556, 581, 557, 579, 559, 578, 560,
    576, 561, 576, 1679, 593, 1667, 606, 559,
    584, 1660, 606, 1665, 608, 1662, 620, 1651,
    602, 1669, 603, 562, 575, 1668, 604, 561,
    576, 561, 575, 563, 573, 1671, 602, 562,
    574, 564, 573, 1671, 601, 563, 574, 1670,
    602, 1669, 604, 1667, 606, 559, 577, 1667,
    605
]

NEC_TV_VOL_UP = [
    # 另一組 pulses
]

NEC_TV_VOL_DOWN = [
    # ...
]

# 用 dict 管理所有遙控碼
CODES = {
    "NEC_TV_POWER": NEC_TV_POWER,
    "NEC_TV_VOL_UP": NEC_TV_VOL_UP,
    "NEC_TV_VOL_DOWN": NEC_TV_VOL_DOWN
}

IR_Receiver.py


from machine import Pin            # 從 machine 模組匯入 Pin,用來操作 GPIO
import time                        # 匯入 time 模組,用來取得微秒時間

ir = Pin(6, Pin.IN)                # 將 GPIO6 設定為輸入腳位,接紅外線接收器輸出腳

pulses = []                        # 建立一個 list,用來儲存每一段脈衝的時間長度
last = time.ticks_us()             # 紀錄上一次中斷發生的時間(微秒)

def irq(pin):                      # 定義 GPIO 中斷處理函式
    global last, pulses             # 使用全域變數 last 與 pulses
    now = time.ticks_us()           # 取得目前時間(微秒)
    pulses.append(                 # 將兩次中斷之間的時間差加入 pulses
        time.ticks_diff(now, last)  # 計算 now 與 last 的差值(避免溢位)
    )
    last = now                     # 更新 last 為本次中斷時間

ir.irq(                            # 設定 GPIO 中斷
    trigger=Pin.IRQ_FALLING | Pin.IRQ_RISING,  # 上升沿與下降沿都觸發
    handler=irq                    # 指定中斷處理函式
)

print("📡 IR Receiver ready")      # 提示紅外線接收器已就緒

while True:
    time.sleep(0.1)  # 100ms 檢查一次

    # 如果已經有資料,而且 50ms 沒再收到新脈衝
    if pulses and time.ticks_diff(time.ticks_us(), last) > 50_000:
        print("📥 pulses 數量 =", len(pulses))
        print("📥 pulses =", pulses)
        pulses = []   # 只在「真的結束」後才清空

IR_Sender.py


from machine import Pin, PWM   # 匯入 Pin 與 PWM 類別,用來控制 GPIO 與硬體 PWM
import time                    # 匯入 time 模組,用來做延遲(微秒級)

# ===== 定義 IR 發射類別 =====
class IRSender:
    def __init__(self, pin=1, freq=38000):
        # 建立 PWM 物件,控制紅外線 LED 載波
        # pin: 使用哪個 GPIO 腳位輸出
        # freq: PWM 頻率,NEC 協議通常 38kHz
        # duty=0: 初始不輸出紅外線
        self.pwm = PWM(Pin(pin), freq=freq, duty=0)

    # 發射紅外線脈衝序列
    # pulses: [mark, space, mark, space ...] 單位為微秒
    def send(self, pulses):
        if not pulses:
            return False             # 若脈衝序列為空,直接返回 False 表示發射失敗

        for i, duration in enumerate(pulses):
            if i % 2 == 0:
                self.pwm.duty(512)  # 偶數 index = mark,紅外線開啟,duty 50%
            else:
                self.pwm.duty(0)    # 奇數 index = space,紅外線關閉
            time.sleep_us(duration)  # 延遲對應微秒數,維持 mark/space 寬度

        self.pwm.duty(0)            # 發射完成後,確保紅外線關閉
        return True                 # 回傳 True 表示發射成功

main.py


import network
import time
import ure

import StaModeHttpServer as http
import OledShowText as oled
from IR_Sender import IRSender
import IR_Codes

# ===== WiFi =====
SSID = "您家的SSID"
PASSWORD = "您家的PASSWORD"

sta = network.WLAN(network.STA_IF)
if sta.active():
    sta.active(False)
time.sleep(0.5)
sta.active(True)
sta.connect(SSID, PASSWORD)

oled.show("WiFi Connecting...")
while not sta.isconnected():
    time.sleep(0.5)

ip = sta.ifconfig()[0]
oled.show("WiFi OK", "IP:", ip)
print("IP:", ip)

# ===== IR =====
ir = IRSender(pin=1)

def send_ir(code_name):
    pulses = IR_Codes.CODES.get(code_name)
    if pulses:
        ir.send(pulses)
        print("IR pulses:", pulses)  
        print("IR 發射:", code_name)  

# ===== HTTP =====
server = http.start_server()
oled.show("HTTP Server Ready", ip)  # OLED 只顯示 IP

def on_request(cl):
    req = cl.recv(1024).decode()
    #print("REQ:", req)

    # 直接用 if 判斷 URL,對應 IR
    for url, code_name in IR_Codes.CODES.items():
        if f"GET /{url} " in req:
            send_ir(url)
            response_body = f"{url} 發射完成"
            break
    else:
        response_body = "未知指令"

    response = (
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/plain; charset=utf-8\r\n\r\n"
        + response_body
    )
    cl.send(response.encode('utf-8'))

# ===== 主迴圈 =====
while True:
    http.handle_client(server, on_request)
    time.sleep_ms(10)


OledShowText.py


from machine import Pin, I2C          # 從 machine 模組匯入 Pin 與 I2C 類別,用來控制 GPIO 與 I2C 通訊
import ssd1306                        # 匯入 SSD1306 OLED 顯示驅動模組

# === 初始化 I2C ===
i2c = I2C(1, scl=Pin(6), sda=Pin(5), freq=400000)  # 建立 I2C 物件,使用 I2C1,SCL 接 GPIO6,SDA 接 GPIO5,頻率 400kHz
oled = ssd1306.SSD1306_I2C(128, 64, i2c)          # 建立 OLED 物件,解析度 128x64,透過上面建立的 I2C 物件控制

# === 顯示文字函式 ===
def show(*lines):                # 定義 show 函式,可傳入多行文字參數
    oled.fill(0)                  # 將 OLED 畫面填滿黑色(清空畫面)
    y = 0                         # 初始 y 座標為 0,用來控制文字行位置
    for line in lines:            # 逐行讀取傳入的文字
        oled.text(line, 0, y)    # 在 OLED 畫面指定 x=0, y 位置顯示文字
        y += 15                   # y 座標增加 15 像素,為下一行文字預留空間
    oled.show()                   # 將畫面更新到 OLED,實際顯示文字


ssd1306.py


# MicroPython SSD1306 OLED driver, I2C and SPI interfaces

from micropython import const
import framebuf

SET_CONTRAST = const(0x81)
SET_ENTIRE_ON = const(0xA4)
SET_NORM_INV = const(0xA6)
SET_DISP = const(0xAE)
SET_MEM_ADDR = const(0x20)
SET_COL_ADDR = const(0x21)
SET_PAGE_ADDR = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP = const(0xA0)
SET_MUX_RATIO = const(0xA8)
SET_COM_OUT_DIR = const(0xC0)
SET_DISP_OFFSET = const(0xD3)
SET_COM_PIN_CFG = const(0xDA)
SET_DISP_CLK_DIV = const(0xD5)
SET_PRECHARGE = const(0xD9)
SET_VCOM_DESEL = const(0xDB)
SET_CHARGE_PUMP = const(0x8D)

class SSD1306:
    def __init__(self, width, height, external_vcc):
        self.width = width
        self.height = height
        self.external_vcc = external_vcc
        self.pages = self.height // 8
        self.buffer = bytearray(self.pages * self.width)
        self.fb = framebuf.FrameBuffer(
            self.buffer, self.width, self.height, framebuf.MONO_VLSB
        )
        self.init_display()

    def init_display(self):
        for cmd in (
            SET_DISP | 0x00,
            SET_MEM_ADDR, 0x00,
            SET_DISP_START_LINE | 0x00,
            SET_SEG_REMAP | 0x01,
            SET_MUX_RATIO, self.height - 1,
            SET_COM_OUT_DIR | 0x08,
            SET_DISP_OFFSET, 0x00,
            SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12,
            SET_DISP_CLK_DIV, 0x80,
            SET_PRECHARGE, 0x22 if self.external_vcc else 0xF1,
            SET_VCOM_DESEL, 0x30,
            SET_CONTRAST, 0xFF,
            SET_ENTIRE_ON,
            SET_NORM_INV,
            SET_CHARGE_PUMP, 0x10 if self.external_vcc else 0x14,
            SET_DISP | 0x01,
        ):
            self.write_cmd(cmd)

    def fill(self, col):
        self.fb.fill(col)

    def pixel(self, x, y, col):
        self.fb.pixel(x, y, col)

    def text(self, string, x, y, col=1):
        self.fb.text(string, x, y, col)

    def show(self):
        self.write_cmd(SET_COL_ADDR)
        self.write_cmd(0)
        self.write_cmd(self.width - 1)
        self.write_cmd(SET_PAGE_ADDR)
        self.write_cmd(0)
        self.write_cmd(self.pages - 1)
        self.write_data(self.buffer)

class SSD1306_I2C(SSD1306):
    def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False):
        self.i2c = i2c
        self.addr = addr
        self.temp = bytearray(2)
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.temp[0] = 0x80
        self.temp[1] = cmd
        self.i2c.writeto(self.addr, self.temp)

    def write_data(self, buf):
        self.i2c.writeto(self.addr, b"\x40" + buf)


StaModeHttpServer.py


import socket  # 匯入 socket 模組,用來建立網路連線(HTTP Server)

# ===== 建立 HTTP Server =====
def start_server():
    addr = socket.getaddrinfo("0.0.0.0", 80)[0][-1]  # 取得 0.0.0.0:80 的地址資訊(監聽所有網卡)
    s = socket.socket()                               # 建立 TCP socket 物件
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)  # 設定 socket 選項,允許重複使用同一個地址
    s.bind(addr)                                     # 綁定 socket 到指定地址(IP + port)
    s.listen(1)                                      # 開始監聽,最多排隊 1 個連線
    s.settimeout(0.1)                                # 設定 accept() 最多等待 0.1 秒,不會阻塞主程式
    return s                                         # 回傳建立好的 socket 物件

# ===== 處理 HTTP Client =====
def handle_client(server, on_request):
    try:
        cl, addr = server.accept()                  # 嘗試接受 client 連線,cl 為 client socket,addr 為 client 地址
    except:
        return                                     # 若沒有連線或超時,直接回傳,不做任何處理

    try:
        on_request(cl)                              # 呼叫傳入的處理函式 on_request(),由使用者定義如何處理 HTTP 請求
    except Exception as e:
        print("處理 client 發生錯誤:", e)           # 捕捉錯誤並印出
    finally:
        cl.close()                                  # 不論是否出錯,都關閉 client socket,釋放資源

執行IR_Receiver.py範例程式

IR_Receiver.py

⚠️ 程式為無限迴圈執行,
若要停止程式,請在Thonny 連按兩次 Ctrl + C
  1. 執行後拿電視遙控器按一下電源鍵,會接收到如下圖的脈衝。 03.png

  2. 把pulses複製出來。(我拿之前錄好的來做)

    
    📥 pulses = [810740, 8860, 4465, 
                  619, 1664, 609, 555, 581, 557, 579, 1665, 
                  608, 556, 581, 557, 579, 559, 578, 560, 
                  576, 561, 576, 1679, 593, 1667, 606, 559, 
                  584, 1660, 606, 1665, 608, 1662, 620, 1651,
                  602, 1669, 603, 562, 575, 1668, 604, 561, 
                  576, 561, 575, 563, 573, 1671, 602, 562, 
                  574, 564, 573, 1671, 601, 563, 574, 1670, 
                  602, 1669, 604, 1667, 606, 559, 577, 1667, 
                  605
                ]
    
  3. 把第1個pulse刪除,810740這個值沒有意義。

    
    📥 pulses = [8860, 4465, 
                  619, 1664, 609, 555, 581, 557, 579, 1665, 
                  608, 556, 581, 557, 579, 559, 578, 560, 
                  576, 561, 576, 1679, 593, 1667, 606, 559, 
                  584, 1660, 606, 1665, 608, 1662, 620, 1651,
                  602, 1669, 603, 562, 575, 1668, 604, 561, 
                  576, 561, 575, 563, 573, 1671, 602, 562, 
                  574, 564, 573, 1671, 601, 563, 574, 1670, 
                  602, 1669, 604, 1667, 606, 559, 577, 1667, 
                  605
                ]
    
    Nec協議可以參考一下這篇: IR - 傳送 IR NEC 原始數據並解碼

打開IR_Codes.py範例程式

  1. 把剛才刪除第1筆後的pulses貼在NEC_TV_POWER這裡,記得按下儲存檔案。 04.png

打開main.py範例程式

main.py

⚠️ 程式為無限迴圈執行,
若要停止程式,請在Thonny 連按兩次 Ctrl + C
  1. 換上您家的WIFI SSID跟密碼,然後執行腳本。 05.png

  2. 連線成功後,列出連線成功後取得的內網IP。 06.png

  3. 用瀏覽器測試一下http://10.0.4.68/NEC_TV_POWER。
    假如有成功會顯示如下圖。 07.png
  4. 這時侯可以考慮找適合的方式讓這個遙控器派上用場。
    假如您有iPad也會用SwiftPlaygrounds,就問Chatgpt,用這個10.0.4.68/NEC_TV_POWER做遙控器View。
    
    我有一塊Esp32已連上網路,透過10.0.4.68/NEC_TV_POWER來發射紅外線脈衝。
    用Swiftui做一個View給我,按鈕按下它會傳送10.0.4.68/NEC_TV_POWER。
    
  5. 如果您沒有iPad只有iPhone,可以透過徑捷來做這個動作,也能透過呼叫Siri來執行這個動作。

⚠ 注意事項

▶ 執行結果

🔗 相關連結