2026年11月14日

Arduino 與感測判斷:感測、決策、動作

ArduinoPython感測器

這篇文章把「開發板負責量測與開關,電腦負責判斷」做成一個可以跟做的小專案。系統分成三層:感測層在 Arduino 讀溫濕度、距離和光線;決策層在電腦用 Python 判斷溫度是否超過 30°C;動作層再把結果送回開發板,開關 LED 與繼電器。

這裡的「判斷」先用清楚的門檻,而不是訓練好的大模型。好處是每一步都看得到:哪一筆資料送出來、為什麼觸發、哪一條指令送回去。後段會說明若以後有紀錄資料,要怎麼把同一條決策函式換成小分類器。

這次測試到哪裡

本篇的 Uno 韌體已用 arduino-cli、板型 arduino:avr:uno 編譯通過(約佔程式空間 25%、動態記憶體 12%)。三層流程則在沒有實體開發板的環境裡,用 Linux PTY 虛擬序列埠加「假裝置」測完:假裝置依照與韌體相同的 DATA/ALERT 文字協定送出溫度,Python 決策層判斷後送回指令,模擬的 LED 與繼電器狀態會跟著改變。低於 30°C、剛好等於 30°C、超過 30°C,以及溫度讀取失敗(nan),都有測到預期結果。

以下項目還沒有在實體硬體上驗證,不能當成硬體測試通過:實際接線、DHT11/DHT22、HC-SR04、光敏電阻的真實讀值、Arduino 透過 USB 序列埠的重設與穩定度、LED 實際亮度、繼電器觸發極性,以及繼電器接上風扇或馬達等負載。ESP32 的 Wi-Fi 路徑只測了電腦端的 TCP 協定;這份 ESP32 韌體在撰文環境中尚未編譯,也未實機測試。

需要的材料

若要用繼電器帶動風扇或馬達,負載必須走繼電器的 COM/NO 與外接電源,不可直接接在 Arduino 的腳位上。這部分需要實體配線,本篇沒有實機驗證。

三層怎麼分工

層跑在哪裡做什麼
感測層Arduino每秒讀一次溫濕度、距離、光線,經序列埠送出一行 DATA,...
決策層電腦上的 Python解析該行資料。溫度超過 30°C(嚴格大於)就送回 ALERT,1,1,否則送 ALERT,0,0
動作層同一份 Arduino 程式收到指令後開關 D5 的 LED 與 D6 的繼電器

預設走 USB 序列埠(9600 8N1)。若以後改用 ESP32,協定不變,只是改走區域網路 TCP,見後段選用路徑。

接線與腳位

下列腳位與程式常數一致,請從頭到尾使用同一套接法。

Arduino Uno 與感測器、LED、繼電器的接線示意5V 接到 DHT22、HC-SR04、繼電器與光敏電阻一端;GND 接到各模組接地、LED 陰極與 10 千歐電阻; D2 接 DHT22 資料腳,D8、D9 接超音波模組的 TRIG 與 ECHO,A0 接光敏電阻分壓中點, D5 經 220 歐電阻接 LED,D6 接繼電器 IN。Arduino Uno5V 邏輯5VGNDD2D8 TRIGD9 ECHOA0D5 LEDD6 RELAYDHT22VCC / DATA / GND也可用 DHT11HC-SR04VCC TRIG ECHO GNDUno 可直接 ECHO光敏電阻分壓5V → 光敏電阻 → A0 → 10kΩ → GND依此接線,A0 讀值愈大通常愈亮實際數值需用實機校正,此處未驗證LEDD5 → 220Ω → 陽極陰極 → GND繼電器模組VCC / GND / IN←D6負載走 COM/NO
接線示意(腳位與下表一致;不是比例精確的線路圖)
元件元件腳位Arduino 腳位說明
DHT22VCC5V改用 DHT11 時接線相同
DHT22GNDGND
DHT22DATAD2若模組沒有內建上拉,DATA 與 5V 之間加 10kΩ
HC-SR04VCC5V
HC-SR04GNDGND
HC-SR04TRIGD8
HC-SR04ECHOD9Uno 為 5V 邏輯,可直接接。3.3V 開發板需分壓,見 ESP32 段
光敏電阻分壓光敏電阻一端5V5V → 光敏電阻 → A0,A0 → 10kΩ → GND
光敏電阻分壓分壓中點A0Uno 讀值範圍 0–1023,依此接線通常愈亮愈大
光敏電阻分壓10kΩ 另一端GND
LED陽極(經 220Ω)D5D5 → 220Ω → LED 陽極,陰極接 GND
LED陰極GND
繼電器模組VCC5V請確認模組適用 5V
繼電器模組GNDGND
繼電器模組IND6程式預設低電平觸發
繼電器負載COM/NO外接電源與負載風扇或馬達不可直接接 Arduino 腳位
接線速記
5V  ──┬── DHT22 VCC
      ├── HC-SR04 VCC
      ├── 繼電器 VCC
      └── 光敏電阻一端

GND ──┬── DHT22 GND
      ├── HC-SR04 GND
      ├── 繼電器 GND
      ├── LED 陰極
      └── 10kΩ 一端

D2  ──── DHT22 DATA(必要時 10kΩ 上拉至 5V)
D5  ──── 220Ω ──── LED 陽極
D6  ──── 繼電器 IN
D8  ──── HC-SR04 TRIG
D9  ──── HC-SR04 ECHO
A0  ──── 光敏電阻與 10kΩ 的交點

感測層:把讀值送出序列埠

在 Arduino IDE 或 arduino-cli 安裝「DHT sensor library」(Adafruit)以及「Adafruit Unified Sensor」。把下面這份程式上傳到 Uno。開機後它會先關閉 LED 與繼電器,等待約兩秒讓 DHT22 穩定,然後每秒送出一行資料。

協定很短,方便用序列埠監控視窗或 Python 一起看:

environment_node.ino
/*
  環境感測節點(感測層 + 動作層)
  開發板:Arduino Uno(ATmega328P 相容板)
  函式庫:Adafruit DHT sensor library(請一併安裝 Adafruit Unified Sensor)

  腳位(請與接線表保持一致):
    DHT22 DATA  -> D2
    HC-SR04 TRIG -> D8
    HC-SR04 ECHO -> D9
    光敏電阻分壓中點 -> A0
    LED(經 220Ω) -> D5
    繼電器模組 IN -> D6(預設低電平觸發)

  序列埠協定(9600 8N1,每筆一行,以換行結尾)
    裝置 → 電腦:DATA,<temp>,<humidity>,<distance_cm>,<light>
      例:DATA,28.50,61.20,45.3,512
      感測失敗時該欄位為 nan(例如 DATA,nan,nan,45.3,512)
    電腦 → 裝置:ALERT,<led>,<relay>
      例:ALERT,1,1
      led、relay 為 0 或 1
*/

#include <DHT.h>

const uint8_t DHT_PIN = 2;
const uint8_t DHT_TYPE = DHT22;
const uint8_t TRIG_PIN = 8;
const uint8_t ECHO_PIN = 9;
const uint8_t LDR_PIN = A0;
const uint8_t LED_PIN = 5;
const uint8_t RELAY_PIN = 6;

// 多數 5V 繼電器模組為低電平觸發:IN 接到 LOW 時接通。
const bool RELAY_ACTIVE_LOW = true;

const unsigned long SAMPLE_INTERVAL_MS = 1000;
const unsigned long ECHO_TIMEOUT_US = 30000;  // 約 5 公尺上限

DHT dht(DHT_PIN, DHT_TYPE);

bool ledOn = false;
bool relayOn = false;
unsigned long lastSampleMs = 0;

void applyOutputs() {
  digitalWrite(LED_PIN, ledOn ? HIGH : LOW);
  if (RELAY_ACTIVE_LOW) {
    digitalWrite(RELAY_PIN, relayOn ? LOW : HIGH);
  } else {
    digitalWrite(RELAY_PIN, relayOn ? HIGH : LOW);
  }
}

void setup() {
  pinMode(TRIG_PIN, OUTPUT);
  pinMode(ECHO_PIN, INPUT);
  pinMode(LED_PIN, OUTPUT);
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(TRIG_PIN, LOW);

  ledOn = false;
  relayOn = false;
  applyOutputs();

  Serial.begin(9600);
  dht.begin();
  delay(2000);  // DHT22 上電穩定期
}

float readDistanceCm() {
  digitalWrite(TRIG_PIN, LOW);
  delayMicroseconds(2);
  digitalWrite(TRIG_PIN, HIGH);
  delayMicroseconds(10);
  digitalWrite(TRIG_PIN, LOW);

  unsigned long duration = pulseIn(ECHO_PIN, HIGH, ECHO_TIMEOUT_US);
  if (duration == 0) {
    return NAN;
  }
  // 聲速約 343 m/s:距離(cm) = duration(us) * 0.0343 / 2
  return (duration * 0.0343f) / 2.0f;
}

void handleCommand(const String& line) {
  // 期望格式:ALERT,1,0
  if (!line.startsWith("ALERT")) {
    return;
  }
  int firstComma = line.indexOf(',');
  int secondComma = line.indexOf(',', firstComma + 1);
  if (firstComma < 0 || secondComma < 0) {
    return;
  }
  int ledVal = line.substring(firstComma + 1, secondComma).toInt();
  int relayVal = line.substring(secondComma + 1).toInt();
  ledOn = ledVal != 0;
  relayOn = relayVal != 0;
  applyOutputs();
}

void loop() {
  while (Serial.available() > 0) {
    String line = Serial.readStringUntil('\n');
    line.trim();
    if (line.length() > 0) {
      handleCommand(line);
    }
  }

  unsigned long now = millis();
  if (now - lastSampleMs < SAMPLE_INTERVAL_MS) {
    return;
  }
  lastSampleMs = now;

  float temperature = dht.readTemperature();  // 攝氏
  float humidity = dht.readHumidity();
  float distanceCm = readDistanceCm();
  int light = analogRead(LDR_PIN);  // 0–1023,依本篇接線,數值愈大通常愈亮

  Serial.print("DATA,");
  if (isnan(temperature)) {
    Serial.print("nan");
  } else {
    Serial.print(temperature, 2);
  }
  Serial.print(",");
  if (isnan(humidity)) {
    Serial.print("nan");
  } else {
    Serial.print(humidity, 2);
  }
  Serial.print(",");
  if (isnan(distanceCm)) {
    Serial.print("nan");
  } else {
    Serial.print(distanceCm, 1);
  }
  Serial.print(",");
  Serial.println(light);
}

決策層:Python 判斷門檻

電腦這端只做一件事:讀一行 DATA,看溫度是否超過門檻(預設 30.0°C,採嚴格大於,剛好 30.00 不觸發),再寫回 ALERT,led,relay。溫度若是 nan,會先把輸出關掉。指令沒有改變時不會重複送,以免一直重設輸出。

先安裝依賴,再指定序列埠執行:

安裝與執行

python3 -m venv .venv
source .venv/bin/activate   # Windows 可用 .venv\Scripts\activate
pip install -r requirements.txt
python decision.py --port /dev/ttyACM0 --threshold 30

序列埠名稱因系統而異:Linux 常見 /dev/ttyACM0 或 /dev/ttyUSB0,macOS 常見 /dev/tty.usbmodem14101,Windows 則是 COM3 這類編號。若不加 --port,程式會嘗試挑一個比較像 Arduino 的埠;不確定時請自己指定。

decision.py
#!/usr/bin/env python3
"""決策層:從序列埠讀取感測資料,依溫度門檻送回動作指令。

依賴:pip install pyserial

預設規則:溫度「超過」門檻(預設 30.0°C,採嚴格大於)時,
同時開啟 LED 與繼電器;溫度讀取失敗時關閉輸出。
"""

from __future__ import annotations

import argparse
import socket
import sys
import time
from typing import Optional

import serial
import serial.tools.list_ports

BAUD = 9600
TEMP_THRESHOLD_C = 30.0

Reading = dict[str, Optional[float] | Optional[int]]


def pick_port(explicit: Optional[str]) -> str:
    if explicit:
        return explicit
    ports = list(serial.tools.list_ports.comports())
    if not ports:
        sys.exit("找不到序列埠。請用 --port 指定,例如 COM3 或 /dev/ttyACM0。")
    keywords = (
        "arduino",
        "usb serial",
        "ch340",
        "cp210",
        "wch",
        "silicon labs",
        "usbmodem",
        "ttyacm",
        "ttyusb",
    )
    for item in ports:
        blob = f"{item.device} {item.description} {item.manufacturer or ''}".lower()
        if any(key in blob for key in keywords):
            return item.device
    return ports[0].device


def parse_data(line: str) -> Optional[dict]:
    """解析 DATA,temp,humidity,distance_cm,light。失敗回傳 None。"""
    parts = line.strip().split(",")
    if len(parts) != 5 or parts[0] != "DATA":
        return None

    def to_float(value: str) -> Optional[float]:
        if value.lower() == "nan":
            return None
        try:
            return float(value)
        except ValueError:
            return None

    def to_int(value: str) -> Optional[int]:
        try:
            return int(value)
        except ValueError:
            return None

    return {
        "temp": to_float(parts[1]),
        "humidity": to_float(parts[2]),
        "distance_cm": to_float(parts[3]),
        "light": to_int(parts[4]),
    }


def decide(reading: dict, temp_threshold: float) -> tuple[int, int, str]:
    """回傳 (led, relay, reason)。

    溫度超過門檻時兩者同時開啟;剛好等於門檻不觸發。
    溫度讀取失敗時關閉輸出。
    """
    temp = reading["temp"]
    if temp is None:
        return 0, 0, "溫度讀取失敗,先關閉輸出"
    if temp > temp_threshold:
        return (
            1,
            1,
            f"溫度 {temp:.2f}°C 超過門檻 {temp_threshold:.1f}°C,觸發警報",
        )
    return 0, 0, f"溫度 {temp:.2f}°C 未超過門檻,維持關閉"


def format_command(led: int, relay: int) -> str:
    return f"ALERT,{led},{relay}"


def process_line(line: str, temp_threshold: float) -> Optional[tuple[dict, str, str]]:
    """處理一列輸入。成功時回傳 (reading, command, reason)。"""
    reading = parse_data(line)
    if reading is None:
        return None
    led, relay, reason = decide(reading, temp_threshold)
    return reading, format_command(led, relay), reason


def run_serial(port: str, temp_threshold: float) -> None:
    print(f"開啟序列埠 {port}({BAUD} bps),門檻 {temp_threshold:.1f}°C")
    with serial.Serial(port, BAUD, timeout=1) as ser:
        time.sleep(2.5)  # 許多 Arduino 在開啟序列埠時會重設
        ser.reset_input_buffer()
        last_cmd: Optional[str] = None
        while True:
            raw = ser.readline()
            if not raw:
                continue
            line = raw.decode("utf-8", errors="ignore").strip()
            result = process_line(line, temp_threshold)
            if result is None:
                if line:
                    print(f"略過無法解析的列:{line}")
                continue
            reading, cmd, reason = result
            print(
                f"T={reading['temp']} H={reading['humidity']} "
                f"D={reading['distance_cm']} L={reading['light']} → {cmd}({reason})"
            )
            if cmd != last_cmd:
                ser.write((cmd + "\n").encode("ascii"))
                last_cmd = cmd


def run_tcp_server(host: str, port: int, temp_threshold: float) -> None:
    """給 ESP32 選用路徑用的 TCP 決策伺服器,協定與序列埠版相同。"""
    print(f"在 {host}:{port} 等待 TCP 連線,門檻 {temp_threshold:.1f}°C")
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
        server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        server.bind((host, port))
        server.listen(1)
        while True:
            conn, addr = server.accept()
            print(f"已連線:{addr[0]}:{addr[1]}")
            last_cmd: Optional[str] = None
            with conn:
                buffer = b""
                while True:
                    chunk = conn.recv(1024)
                    if not chunk:
                        print("對端關閉連線")
                        break
                    buffer += chunk
                    while b"\n" in buffer:
                        raw, buffer = buffer.split(b"\n", 1)
                        line = raw.decode("utf-8", errors="ignore").strip()
                        result = process_line(line, temp_threshold)
                        if result is None:
                            if line:
                                print(f"略過無法解析的列:{line}")
                            continue
                        reading, cmd, reason = result
                        print(
                            f"T={reading['temp']} H={reading['humidity']} "
                            f"D={reading['distance_cm']} L={reading['light']} "
                            f"→ {cmd}({reason})"
                        )
                        if cmd != last_cmd:
                            conn.sendall((cmd + "\n").encode("ascii"))
                            last_cmd = cmd


def main() -> None:
    parser = argparse.ArgumentParser(description="Arduino / ESP32 感測資料決策層")
    parser.add_argument(
        "--port",
        help="序列埠,例如 COM3、/dev/ttyACM0、/dev/tty.usbmodem14101",
    )
    parser.add_argument(
        "--threshold",
        type=float,
        default=TEMP_THRESHOLD_C,
        help="溫度門檻(攝氏),預設 30",
    )
    parser.add_argument(
        "--tcp",
        action="store_true",
        help="改開 TCP 伺服器(ESP32 Wi-Fi 路徑)",
    )
    parser.add_argument("--bind", default="0.0.0.0", help="TCP 綁定位址")
    parser.add_argument("--tcp-port", type=int, default=5000, help="TCP 連接埠")
    args = parser.parse_args()

    if args.tcp:
        run_tcp_server(args.bind, args.tcp_port, args.threshold)
        return

    port = pick_port(args.port)
    run_serial(port, args.threshold)


if __name__ == "__main__":
    main()
requirements.txt
pyserial>=3.5

動作層:依指令開關輸出

動作層寫在同一份 environment_node.ino 裡,不必再上傳另一個程式。開發板收到 ALERT,1,1 會點亮 D5 的 LED,並把 D6 的繼電器接通;收到 ALERT,0,0 則兩者關閉。

多數 5V 繼電器模組是低電平觸發,所以程式預設 RELAY_ACTIVE_LOW = true:要「接通」時,IN 腳反而輸出 LOW。若你接上之後動作相反,把這個常數改成 false 再上傳。繼電器有沒有真的吸合、接負載後能不能帶動風扇或馬達,都需要實體測試。

在瀏覽器裡先看門檻怎麼作用

若還沒接板,可以先用下面的拉桿看決策規則:溫度超過門檻時,畫面上的 LED 與繼電器會開啟,並顯示對應的 DATA/ALERT 列。這只是瀏覽器模擬,不是感測器讀值。

決策層模擬

拉桿調整溫度,看會不會觸發警報

15°C45°C
20°C40°C

溫度 28.00°C 未超過門檻,維持關閉。

LED
繼電器
DATA,28.00,61.20,45.3,512
ALERT,0,0

這是瀏覽器裡的決策模擬,濕度、距離與光線用固定示例值,只演示溫度門檻。它不是感測器的真實讀值,也不能代替實體接線測試。

從頭跑一次

  1. 依腳位表接線,檢查 5V/GND 沒有接反。馬達或風扇不要直接接腳位。
  2. 安裝 DHT 函式庫,上傳 environment_node.ino。
  3. 打開序列埠監控視窗,設定 9600。應看到每秒一行 DATA,...。若溫度一直是 nan,先查 DHT 接線與上拉電阻。
  4. 關掉監控視窗(同一序列埠一次只能被一個程式占用),執行 python decision.py --port 你的埠。
  5. 用暖風或手握住 DHT 讓溫度超過 30°C,觀察終端機是否印出觸發原因,LED 是否亮起、繼電器是否吸合;離開熱源後應再關閉。

最後一步必須在實體硬體上做。撰文時只驗證了編譯,以及虛擬序列埠上的協定與狀態切換。

選用:改走 ESP32 與 Wi-Fi

若要把電腦和開發板分開,可改用 ESP32,仍使用同一套 DATA/ALERT 文字協定,只是改走 TCP。電腦執行 python decision.py --tcp --tcp-port 5000,開發板連到電腦在區域網路中的 IP。

ESP32 是 3.3V 邏輯,腳位也與 Uno 不同,請改用下表,不要把兩套腳位混在一起。

元件ESP32 腳位注意
DHT22 DATAGPIO4模組電源請依規格接 3.3V 或 5V
HC-SR04 TRIGGPIO18
HC-SR04 ECHOGPIO19ECHO 常為 5V,請先分壓再進腳位,例如 ECHO → 1kΩ → GPIO19 → 2kΩ → GND
光敏電阻中點GPIO34僅能輸入;ADC 預設 12 位元,光線欄位範圍是 0–4095,不是 Uno 的 0–1023
LEDGPIO25同樣經 220Ω;LED 電源不要超過 3.3V 邏輯可承受的範圍
繼電器 INGPIO26模組 VCC 仍依規格接 5V 或 3.3V

使用前把程式裡的 YOUR_SSID、YOUR_PASSWORD 和 SERVER_HOST 改成你的網路與電腦 IP。這份韌體在撰文環境中沒有編譯;決策層的 TCP 收送已在本機迴圈測試過,Wi-Fi 連線、分壓電路與實機讀值都還沒測。

environment_node_esp32.ino
/*
  環境感測節點(ESP32 選用路徑:同一套 DATA/ALERT 協定,改走 Wi-Fi TCP)
  開發板:ESP32 Dev Module
  函式庫:Adafruit DHT sensor library、Arduino ESP32 內建 WiFi.h

  腳位(與 Uno 版不同,請勿混用):
    DHT22 DATA  -> GPIO4
    HC-SR04 TRIG -> GPIO18
    HC-SR04 ECHO -> GPIO19(HC-SR04 的 ECHO 為 5V,請經分壓再進 ESP32)
    光敏電阻分壓中點 -> GPIO34(僅輸入、可 ADC)
    LED(經 220Ω,LED 另接 GND) -> GPIO25
    繼電器模組 IN -> GPIO26(預設低電平觸發;模組 VCC 請依規格接 5V 或 3.3V)

  協定與 Uno 版相同,只是改走 TCP:
    裝置 → 電腦:DATA,<temp>,<humidity>,<distance_cm>,<light>
    電腦 → 裝置:ALERT,<led>,<relay>

  注意:ESP32 ADC 預設為 12 位元,光線欄位範圍是 0–4095,不是 Uno 的 0–1023。
*/

#include <WiFi.h>
#include <DHT.h>

const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
const char* SERVER_HOST = "192.168.50.10";  // 改成電腦在區域網路的 IP
const uint16_t SERVER_PORT = 5000;

const uint8_t DHT_PIN = 4;
const uint8_t DHT_TYPE = DHT22;
const uint8_t TRIG_PIN = 18;
const uint8_t ECHO_PIN = 19;
const uint8_t LDR_PIN = 34;
const uint8_t LED_PIN = 25;
const uint8_t RELAY_PIN = 26;
const bool RELAY_ACTIVE_LOW = true;

const unsigned long SAMPLE_INTERVAL_MS = 1000;
const unsigned long ECHO_TIMEOUT_US = 30000;
const unsigned long RECONNECT_INTERVAL_MS = 3000;

DHT dht(DHT_PIN, DHT_TYPE);
WiFiClient client;

bool ledOn = false;
bool relayOn = false;
unsigned long lastSampleMs = 0;
unsigned long lastReconnectMs = 0;

void applyOutputs() {
  digitalWrite(LED_PIN, ledOn ? HIGH : LOW);
  if (RELAY_ACTIVE_LOW) {
    digitalWrite(RELAY_PIN, relayOn ? LOW : HIGH);
  } else {
    digitalWrite(RELAY_PIN, relayOn ? HIGH : LOW);
  }
}

void handleCommand(const String& line) {
  if (!line.startsWith("ALERT")) {
    return;
  }
  int firstComma = line.indexOf(',');
  int secondComma = line.indexOf(',', firstComma + 1);
  if (firstComma < 0 || secondComma < 0) {
    return;
  }
  int ledVal = line.substring(firstComma + 1, secondComma).toInt();
  int relayVal = line.substring(secondComma + 1).toInt();
  ledOn = ledVal != 0;
  relayOn = relayVal != 0;
  applyOutputs();
}

void ensureConnected() {
  if (client.connected()) {
    return;
  }
  unsigned long now = millis();
  if (now - lastReconnectMs < RECONNECT_INTERVAL_MS) {
    return;
  }
  lastReconnectMs = now;
  client.connect(SERVER_HOST, SERVER_PORT);
}

void setup() {
  pinMode(TRIG_PIN, OUTPUT);
  pinMode(ECHO_PIN, INPUT);
  pinMode(LED_PIN, OUTPUT);
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(TRIG_PIN, LOW);
  ledOn = false;
  relayOn = false;
  applyOutputs();

  Serial.begin(115200);
  dht.begin();

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
    delay(200);
  }
  delay(2000);
}

float readDistanceCm() {
  digitalWrite(TRIG_PIN, LOW);
  delayMicroseconds(2);
  digitalWrite(TRIG_PIN, HIGH);
  delayMicroseconds(10);
  digitalWrite(TRIG_PIN, LOW);

  unsigned long duration = pulseIn(ECHO_PIN, HIGH, ECHO_TIMEOUT_US);
  if (duration == 0) {
    return NAN;
  }
  return (duration * 0.0343f) / 2.0f;
}

void loop() {
  ensureConnected();

  while (client.connected() && client.available() > 0) {
    String line = client.readStringUntil('\n');
    line.trim();
    if (line.length() > 0) {
      handleCommand(line);
    }
  }

  unsigned long now = millis();
  if (now - lastSampleMs < SAMPLE_INTERVAL_MS) {
    return;
  }
  lastSampleMs = now;
  if (!client.connected()) {
    return;
  }

  float temperature = dht.readTemperature();
  float humidity = dht.readHumidity();
  float distanceCm = readDistanceCm();
  int light = analogRead(LDR_PIN);  // 0–4095

  String line = "DATA,";
  if (isnan(temperature)) {
    line += "nan";
  } else {
    line += String(temperature, 2);
  }
  line += ",";
  if (isnan(humidity)) {
    line += "nan";
  } else {
    line += String(humidity, 2);
  }
  line += ",";
  if (isnan(distanceCm)) {
    line += "nan";
  } else {
    line += String(distanceCm, 1);
  }
  line += ",";
  line += String(light);
  client.println(line);
  Serial.println(line);  // 本機除錯用
}

選用:把門檻換成小模型

預設的 decide() 已經把「輸入讀值、輸出 led/relay」隔開。若你之後蒐集了實機紀錄,可以在同一個位置改呼叫小分類器,例如 scikit-learn 的決策樹,不必改 Arduino 協定。沒有自己的資料以前,不建議先導入別人訓練好的權重;門檻規則本身就可以完整跑完這個小專案。

把 decide() 換成已訓練模型的示意(需自行準備資料,本篇未訓練、也未執行)

import joblib

model = joblib.load("alert_model.joblib")

def decide(reading, temp_threshold):
    temp = reading["temp"]
    if temp is None:
        return 0, 0, "溫度讀取失敗,先關閉輸出"
    features = [[
        temp,
        reading["humidity"] or 0.0,
        reading["distance_cm"] or 0.0,
        reading["light"] or 0,
    ]]
    label = int(model.predict(features)[0])
    reason = "小模型判定觸發" if label == 1 else "小模型判定關閉"
    return label, label, reason

常見問題

序列埠一打開,Arduino 就重啟?

許多 Uno 在電腦開啟序列埠時會自動重設。decision.py 因此會先等 2.5 秒再開始讀資料。請先關上 Arduino IDE 的監控視窗,再執行 Python。

溫度一直是 nan?

查 DHT 的 VCC/GND/DATA、上拉電阻,以及 DHT_TYPE 是否與實際模組相符。DHT22 上電後也需要一兩秒才穩定。

距離一直是 nan?

查 TRIG/ECHO 是否接反,以及物體是否超出模組範圍。若改用 ESP32 卻沒做 ECHO 分壓,腳位可能無法正確讀到高電平,甚至有損壞風險。

繼電器的動作反了?

把 RELAY_ACTIVE_LOW 改成相反值後重新上傳。先不要接高壓負載,用模組上的指示燈確認極性。

Linux 提示沒有權限開序列埠?

把使用者加入 dialout 群組後重新登入,或暫時確認裝置節點的權限。這屬於實機環境設定,撰文時未在實體 USB 裝置上操作。

下載

下列檔案與文中程式碼相同,可直接下載後編譯或執行。