Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 17 章 專題製作

本章目標

  • 學完你會照著「問題 → 規劃 → 接線 → 程式 → 測試 → 展示」六個步驟,把一個想法變成會動的作品。
  • 學完你會獨立完成五個專題中的至少兩個:一個用 UNO、一個用 ESP32。
  • 學完你會用示波器或邏輯分析儀「證明」你的作品真的照你想的方式在運作,而不是只看它會不會動。
  • 學完你會寫一份有量測數據的專題報告,並在三分鐘內把作品講清楚。

開場:從「會動」到「我知道它為什麼會動」

社團成果發表會前兩週,小安把一個夜燈接好了:天黑 LED 就亮,天亮就暗。她很開心,直到隔壁的學長問了一句:「你的燈是漸漸變亮,還是一下子跳亮?」她愣住了。肉眼看起來好像是漸漸的……但她沒辦法確定,也說不出 LED 收到的訊號到底長什麼樣。

學長把示波器探棒鉤上去,螢幕上出現一串方波,寬窄正在慢慢變化。「這叫占空比,你第 14 章學過。你的燈的確是漸亮的,而且從全暗到全亮花了大概 2.5 秒。把這張波形放進報告,評審一看就知道你不只是接對線,你還懂它。」

這一章就是要帶你走到那一步。前面十六章你學了元件、儀器和程式;現在要把它們組合成完整的作品,而且每一個作品都要能用儀器拿出證據。五個專題難度由淺到深,你可以挑一個做,也可以全部做完。最後我們會談怎麼寫報告、怎麼上台。

核心概念

專題的六個步驟

比喻:做專題像蓋一間樹屋。你不會一開始就釘木板,而是先想「要給誰用、要多大」(問題),畫個草圖、列材料(規劃),再搭骨架(接線)、裝門窗(程式),爬上去跳一跳看會不會垮(測試),最後邀朋友來參觀(展示)。

 ┌──────┐   ┌──────┐   ┌──────┐   ┌──────┐   ┌──────┐   ┌──────┐
 │ 問題 │──►│ 規劃 │──►│ 接線 │──►│ 程式 │──►│ 測試 │──►│ 展示 │
 └──────┘   └──────┘   └──────┘   └──────┘   └──┬───┘   └──────┘
                 ▲                                │
                 └────── 測試不過就回頭改 ─────────┘

1. 問題:用一句話說清楚「誰、在什麼情況下、需要什麼」。例如:「晚上起床上廁所的人,需要一盞不刺眼、會自己亮的小燈。」寫不出這句話,後面都會很模糊。

2. 規劃:把問題拆成「輸入 → 處理 → 輸出」三格,再把每一格填上你會的元件。

輸入(感測)處理(微控制器做判斷)輸出(動作)
光敏電阻量亮度太暗就慢慢調高 PWMLED 漸亮

填好以後,列材料清單,並且翻回前面的章節確認每個元件你都接過。這一步最常被跳過,也最常導致後面卡住。

3. 接線:先接電源和 GND,再接輸入,最後接輸出。每接完一部分就用三用電表量一下該有電壓的地方有沒有電壓。一次只接一個部分。

4. 程式:先寫「最小可動版本」,例如只讀感測器印到序列埠;確認數字合理,再加判斷;最後加輸出。每加一段就上傳一次。千萬不要一口氣寫完 100 行才第一次上傳。

5. 測試:分三層。第一層「看起來會動」;第二層「序列埠印出的數字合理」;第三層「用示波器或邏輯分析儀量到的波形跟程式設定的一樣」。本章每個專題都會做到第三層。

6. 展示:準備一張海報或三張投影片、一段兩分鐘的現場操作、一份附量測數據的報告。章末會細講。

專題程式的共同骨架

五個專題的程式看起來很長,但骨架都一樣:

// 1. 腳位與常數:所有數字集中放在最上面,要調整時不用到處找
// 2. 全域變數:記住「現在的狀態」,例如 LED 亮度、風扇段速
// 3. 小函式:每個函式只做一件事(讀感測器、更新螢幕、處理網頁)
// 4. setup():設定腳位、啟動序列埠、連 Wi-Fi、初始化螢幕
// 5. loop():用 millis() 排程,不要用長 delay()

第 5 點在 ESP32 專題裡特別重要:網頁伺服器需要 loop() 一直跑 server.handleClient(),一個 delay(2000) 就會讓手機按鈕兩秒沒反應。寫法是記住「上次做這件事的時間」,每圈 loop() 檢查「現在減上次」有沒有超過間隔:

if (millis() - lastReadTime >= 2000) {   // 距離上次讀已經 2 秒了嗎?
  lastReadTime = millis();               // 記住這次的時間
  readSensor();                          // 做該做的事
}

除錯的三個習慣

  1. 先量電,再看程式:七成的「不會動」是接線問題。三用電表量 VCC 和 GND 之間有沒有正確電壓,是第一件事。
  2. 每個關鍵數值都 Serial.print():感測器讀值、算出來的 PWM 值、收到的網頁路徑。看得到數字才知道哪一段出錯。
  3. 一次只改一個地方:改了接線就不要同時改程式。不然你不知道是哪個改動讓它好起來或壞掉。

動手做

每個專題的結構都一樣:目標 → 材料 → 接線 → 程式 → 測試方法 → 延伸挑戰。「測試方法」裡至少有一項要用示波器或邏輯分析儀,請把量到的數字填進表格,這些就是你報告裡的證據。

實驗 17-1:專題一 智慧夜燈

目標

做一盞用光敏電阻感應環境亮度的夜燈。環境越暗,LED 越亮;而且亮度是漸漸變化的,不會一下子跳亮或跳暗。整個過程用示波器證明 PWM 占空比真的在連續變化。

材料

  • Arduino UNO ×1
  • 光敏電阻(LDR)×1
  • 10kΩ 電阻 ×1
  • 白色 LED ×1(紅色也可以,但夜燈用白色或暖色比較像真的)
  • 220Ω 電阻 ×1
  • 麵包板、杜邦線

接線

 UNO 5V ────[ LDR ]────┬────[ 10kΩ ]──── GND
                       │
                    UNO A0

 UNO D9 ──[ 220Ω ]──►|── GND
                    白色 LED

文字說明:

  • UNO 5V → 光敏電阻一腳;光敏電阻另一腳 → A0,同一點 → 10kΩ → GND。
  • UNO D9 → 220Ω → LED 長腳;LED 短腳 → GND。

光敏電阻在亮的時候電阻小(幾百歐姆),暗的時候電阻大(幾十 kΩ 以上)。這個分壓器讓 A0 在亮的時候電壓高、暗的時候電壓低,第 13 章做過。

程式

// 專題一:智慧夜燈(UNO)
// 天暗 → LED 漸亮;天亮 → LED 漸暗

const int ldrPin = A0;         // 光敏電阻分壓點
const int ledPin = 9;          // PWM 腳位,接 LED

// 這兩個數值要依你的教室調整:先看序列埠,記下「蓋住」和「正常光」的讀值
const int darkValue   = 200;   // 低於這個算全暗 → LED 全亮
const int brightValue = 700;   // 高於這個算全亮 → LED 全暗

int currentBrightness = 0;     // 目前 LED 的 PWM 值(0–255)
const int fadeStepDelay = 10;  // 每 10 毫秒調整 1,全程 255 步 ≈ 2.5 秒

void setup() {
  pinMode(ledPin, OUTPUT);
  Serial.begin(9600);
  Serial.println("smart night light start");
}

void loop() {
  int light = analogRead(ldrPin);                    // 0–1023,越亮越大

  // 把亮度讀值對應成「目標 LED 亮度」:暗 → 255,亮 → 0
  int target = map(light, darkValue, brightValue, 255, 0);
  target = constrain(target, 0, 255);               // 超出範圍就夾在 0–255

  // 每圈只往目標走一步,所以變化是漸漸的
  if (currentBrightness < target) {
    currentBrightness++;
  } else if (currentBrightness > target) {
    currentBrightness--;
  }
  analogWrite(ledPin, currentBrightness);           // 輸出 PWM

  // 印成「名稱:數值」格式,序列繪圖器會畫成兩條線
  Serial.print("light:");
  Serial.print(light);
  Serial.print(",led:");
  Serial.println(currentBrightness);

  delay(fadeStepDelay);
}

步驟

  1. 接線完成後,先用三用電表量 A0 對 GND:正常光線下應該有 2–4V,用手蓋住光敏電阻會掉到 1V 以下。沒有變化就是分壓器接錯了。
  2. 上傳程式,打開序列繪圖器(工具 → 序列繪圖器,鮑率 9600)。
  3. 看 light 那條線,在正常光線和蓋住的情況各記一個數值,填進程式的 brightValue 和 darkValue,重新上傳。
  4. 用手慢慢蓋住光敏電阻,看 LED 和 led 那條線。

你應該看到

蓋住光敏電阻後,light 線立刻往下掉,但 led 線是一條斜斜往上爬的線,花大約 2.5 秒才到 255;LED 也跟著慢慢變亮。放開手則反過來。

測試方法(示波器)

探棒鉤 D9,鱷魚夾接 GND。設定 2 V/div、500 µs/div、觸發 CH1 上升緣 2.5V。

電壓(LED 全暗,占空比 0%)
 5V ┤
 0V ┼──────────────────────────── 時間

電壓(半亮,占空比約 50%)
 5V ┤  ┌────┐    ┌────┐    ┌────┐
 0V ┼──┘    └────┘    └────┘    └─

電壓(LED 全亮,占空比 100%)
 5V ┤──────────────────────────────
 0V ┼
      ◄────────►
       約 2.04 ms(490 Hz)

用手慢慢蓋住光敏電阻,你會看到方波的「高的部分」慢慢變寬,直到整條線貼在 5V。用示波器的自動量測功能讀「頻率」和「正占空比(+Duty)」,填表:

情況序列埠的 led 值示波器占空比算出來的 led 值(占空比 × 255)頻率
正常光線
半蓋住
全蓋住

舉例:如果示波器讀到占空比 40%,0.40 × 255 = 102,應該跟序列埠印的 led 值差不多。頻率應該接近 490 Hz,這是 UNO D9 的 PWM 頻率。

延伸挑戰

  • 加一顆按鈕(D2,INPUT_PULLUP):按一下切換「自動/強制關燈」,半夜不想被亮到的時候可以關掉。
  • 把 LED 換成 2N2222 電晶體驅動三顆白色 LED 並聯(第 9 章接法),亮度夠照亮床邊。
  • 加一個「亮了 30 分鐘就自動變暗」的功能,用 millis() 計時。

實驗 17-2:專題二 溫濕度氣象站

目標

做一台放在教室的氣象站:OLED 顯示目前溫濕度和今天的最高、最低溫;手機打開網頁可以看到目前數值和最近 24 筆溫度的長條圖。用邏輯分析儀證明 ESP32 確實用 I2C 在跟 OLED 講話。

材料

  • ESP32 DevKit V1 ×1
  • DHT11 溫濕度感測器 ×1
  • 0.96 吋 OLED(SSD1306,I2C)×1
  • 10kΩ 電阻 ×1(裸元件四腳版 DHT11 才需要)
  • 麵包板、杜邦線
  • USB 邏輯分析儀(測試用)

接線

                 ┌─────────────────┐
     3V3 ────────┤ VCC    DHT11    │
     GPIO 4 ─────┤ DATA            │
     GND ────────┤ GND             │
                 └─────────────────┘
                 ┌─────────────────┐
     3V3 ────────┤ VCC    OLED     │
     GND ────────┤ GND   SSD1306   │
     GPIO 21 ────┤ SDA             │
     GPIO 22 ────┤ SCL             │
                 └─────────────────┘

文字說明:跟第 16 章實驗 16-4 完全一樣。DHT11 DATA → GPIO 4;OLED SDA → GPIO 21、SCL → GPIO 22;兩個元件都用 3V3 和 GND。

程式

// 專題二:溫濕度氣象站(ESP32)
// OLED 顯示目前值與最高最低溫,網頁顯示數值與最近 24 筆長條圖
#include <WiFi.h>
#include <WebServer.h>
#include <DHT.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

const char* ssid     = "你的Wi-Fi名稱";
const char* password = "你的Wi-Fi密碼";

#define DHTPIN 4
DHT dht(DHTPIN, DHT11);
Adafruit_SSD1306 display(128, 64, &Wire, -1);
WebServer server(80);

float temperature = 0, humidity = 0;      // 目前值
float tempMax = -100, tempMin = 100;      // 開機以來的最高、最低溫

const int HISTORY_SIZE = 24;              // 記最近 24 筆
float tempHistory[HISTORY_SIZE];
int historyCount = 0;                     // 目前記了幾筆
// 展示時可以把 60000 改成 5000(每 5 秒一筆),長條圖很快就會長出來
const unsigned long HISTORY_INTERVAL = 60000UL;
unsigned long lastHistoryTime = 0;
unsigned long lastReadTime = 0;

// 讀感測器並更新最高最低
void readSensor() {
  float t = dht.readTemperature();
  float h = dht.readHumidity();
  if (isnan(t) || isnan(h)) {             // 讀失敗就跳過這次
    Serial.println("DHT11 read failed");
    return;
  }
  temperature = t;
  humidity = h;
  if (t > tempMax) tempMax = t;           // 更新紀錄
  if (t < tempMin) tempMin = t;
}

// 把目前溫度加進歷史紀錄,滿了就把最舊的擠掉
void addHistory() {
  if (historyCount < HISTORY_SIZE) {
    tempHistory[historyCount] = temperature;
    historyCount++;
  } else {
    for (int i = 0; i < HISTORY_SIZE - 1; i++) {
      tempHistory[i] = tempHistory[i + 1]; // 全部往前移一格
    }
    tempHistory[HISTORY_SIZE - 1] = temperature;
  }
}

// OLED 畫面
void showOnOled() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("http://");
  display.println(WiFi.localIP());        // 第一行印網址

  display.setTextSize(2);
  display.setCursor(0, 16);
  display.print(temperature, 1);
  display.print("C ");
  display.print(humidity, 0);
  display.println("%");

  display.setTextSize(1);                 // 第三行印最高最低
  display.setCursor(0, 44);
  display.print("Max ");
  display.print(tempMax, 1);
  display.print("  Min ");
  display.println(tempMin, 1);
  display.setCursor(0, 56);
  display.print("Up ");
  display.print(millis() / 60000);        // 開機幾分鐘
  display.println(" min");
  display.display();
}

// 網頁:目前值、最高最低、長條圖
void handleRoot() {
  String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>";
  html += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
  html += "<meta http-equiv='refresh' content='10'>";
  html += "<title>教室氣象站</title></head>";
  html += "<body style='font-family:sans-serif;margin:20px'>";
  html += "<h1>教室氣象站</h1>";
  html += "<p style='font-size:2.5em;margin:0'>" + String(temperature, 1) + " &deg;C</p>";
  html += "<p style='font-size:2.5em;margin:0'>" + String(humidity, 0) + " %</p>";
  html += "<p>最高 " + String(tempMax, 1) + " &deg;C 最低 " + String(tempMin, 1) + " &deg;C";
  html += " 已開機 " + String(millis() / 60000) + " 分鐘</p>";
  html += "<h2>最近 " + String(historyCount) + " 筆溫度</h2>";

  // 每一筆畫一條長條:溫度 10°C = 0 像素,40°C = 300 像素
  for (int i = 0; i < historyCount; i++) {
    int width = (int)((tempHistory[i] - 10.0) * 10.0);
    width = constrain(width, 2, 300);
    html += "<div style='display:flex;align-items:center;margin:2px'>";
    html += "<div style='background:#4a90e2;height:14px;width:" + String(width) + "px'></div>";
    html += "<span style='margin-left:6px'>" + String(tempHistory[i], 1) + "</span></div>";
  }
  html += "</body></html>";
  server.send(200, "text/html; charset=utf-8", html);
}

void setup() {
  Serial.begin(115200);
  dht.begin();
  Wire.begin(21, 22);
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("OLED not found");
    while (true) delay(10);
  }
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("Connecting WiFi...");
  display.display();

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("http://");
  Serial.println(WiFi.localIP());

  server.on("/", handleRoot);
  server.begin();

  delay(1500);                              // DHT11 開機要一點時間
  readSensor();
  addHistory();                             // 第一筆
  showOnOled();
}

void loop() {
  server.handleClient();

  if (millis() - lastReadTime >= 2000) {    // 每 2 秒讀一次、更新 OLED
    lastReadTime = millis();
    readSensor();
    showOnOled();
  }
  if (millis() - lastHistoryTime >= HISTORY_INTERVAL) {  // 定時記一筆
    lastHistoryTime = millis();
    addHistory();
    Serial.print("history ");
    Serial.print(historyCount);
    Serial.print(" : ");
    Serial.println(temperature);
  }
}

步驟

  1. 填入 Wi-Fi 名稱密碼,上傳。
  2. OLED 第一行出現網址,手機輸入。
  3. 展示時把 HISTORY_INTERVAL 改成 5000,讓長條圖在兩分鐘內長出 24 條;正式使用再改回 60000。
  4. 用手捏住 DHT11 一分鐘,看最高溫紀錄會不會更新。

你應該看到

OLED 顯示三行:網址、大字溫濕度、最高最低與開機時間。網頁每 10 秒更新,長條圖一條一條增加,捏住感測器後長條變長。

測試方法(邏輯分析儀)

邏輯分析儀 CH0 → GPIO 21(SDA),CH1 → GPIO 22(SCL),GND → ESP32 GND。PulseView 取樣率 2 MHz、取樣 2 秒,加入 I2C 解碼器(SDA=CH0、SCL=CH1)。

SCL ─┐ ┌┐ ┌┐ ┌┐ ┌┐ ┌┐ ┌┐ ┌┐ ┌┐ ┌─
      └┘ └┘ └┘ └┘ └┘ └┘ └┘ └┘ └┘
SDA ─┐   ┌──┐  ┌─────┐     ┌──  ...
      └───┘  └──┘     └─────┘
      S  0  1  1  1  1  0  0  W  A   ← 解碼器顯示:位址 0x3C,寫入,ACK

每 2 秒應該出現一大串 I2C 封包(那是 display.display() 把整個畫面 1024 個位元組送過去)。填表:

項目你量到的
解碼出的 OLED 位址(應該是 0x3C)
兩串封包之間的間隔(應該約 2 秒)
一串封包總共多長(毫秒)
SCL 一個週期的時間 → 算出 I2C 時脈頻率

舉例:如果 SCL 一個週期是 10 µs,頻率就是 1 ÷ 0.00001 = 100 kHz,這是 I2C 的標準速度。

延伸挑戰

  • 加入濕度的長條圖,用不同顏色。
  • 用第 16 章的深度睡眠,改成電池供電版本:醒來 10 秒把數值印在 OLED 上,然後睡 5 分鐘。
  • 濕度超過 80% 時讓 GPIO 2 的 LED 閃爍提醒。

實驗 17-3:專題三 手機 Wi-Fi 遙控風扇

目標

做一台可以用手機網頁控制「關/低/中/高」四段速的小風扇。ESP32 輸出 PWM,經 IRLZ44N MOSFET 驅動直流馬達。用示波器量 MOSFET 閘極與汲極的波形,證明三段速真的是三種占空比。

材料

  • ESP32 DevKit V1 ×1
  • N 通道 MOSFET IRLZ44N ×1
  • 小直流馬達(3–6V)×1,可以在軸上黏一片紙板當扇葉
  • 整流二極體 1N4007 ×1(飛輪二極體)
  • 220Ω 電阻 ×1(閘極電阻)
  • 10kΩ 電阻 ×1(閘極下拉)
  • 3 號電池盒(3 顆,4.5V)×1
  • 麵包板、杜邦線

接線

                              IRLZ44N(正面看,腳位由左到右 G D S)
 ESP32 GPIO 18 ──[ 220Ω ]──┬── G
                           │
                        [ 10kΩ ]
                           │
                          GND

 電池盒 +4.5V ──┬───( 馬達 )───┬── D
               │              │
               └───|◄─────────┘        1N4007:有條紋的那端朝電池正極
                 1N4007

 S ── GND ──┬── 電池盒 −
            └── ESP32 GND(一定要跟電池盒負極接在一起)

文字說明:

  • ESP32 GPIO 18 → 220Ω → IRLZ44N 閘極(G);G 另外 → 10kΩ → GND(確保 ESP32 還沒開機時 MOSFET 是關的)。
  • 電池盒正極 → 馬達一腳;馬達另一腳 → IRLZ44N 汲極(D)。
  • 1N4007 跨接在馬達兩端:有條紋的一端接電池正極那側,另一端接汲極那側。沒有它,關掉馬達時的尖波會打壞 MOSFET(第 7 章看過)。
  • IRLZ44N 源極(S)→ GND。電池盒負極、ESP32 GND、MOSFET 的 S 三個點要接在一起。
  • 不要用 ESP32 的 3V3 腳供電給馬達,馬達啟動電流會讓 ESP32 重開。

程式

// 專題三:手機 Wi-Fi 遙控風扇(ESP32 + IRLZ44N)
#include <WiFi.h>
#include <WebServer.h>

const char* ssid     = "你的Wi-Fi名稱";
const char* password = "你的Wi-Fi密碼";

const int fanPin   = 18;          // PWM 輸出 → MOSFET 閘極
const int PWM_FREQ = 1000;        // 1 kHz,馬達不會發出刺耳的聲音
const int PWM_RES  = 8;           // 8 位元 → 0–255

int fanLevel = 0;                 // 0 關、1 低、2 中、3 高
const int levelDuty[4] = {0, 110, 180, 255};   // 各段的 PWM 值;低速太小馬達會轉不動
const char* levelName[4] = {"關", "低", "中", "高"};

WebServer server(80);

// 設定風扇段速
void setFan(int level) {
  fanLevel = constrain(level, 0, 3);
  ledcWrite(fanPin, levelDuty[fanLevel]);     // 輸出對應的占空比
  Serial.print("fan level ");
  Serial.print(fanLevel);
  Serial.print(" duty ");
  Serial.println(levelDuty[fanLevel]);
}

// 網頁:四顆大按鈕
String makePage() {
  String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>";
  html += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
  html += "<title>Wi-Fi 風扇</title></head>";
  html += "<body style='font-family:sans-serif;text-align:center;margin-top:30px'>";
  html += "<h1>Wi-Fi 風扇</h1>";
  html += "<p style='font-size:1.5em'>目前:<b>" + String(levelName[fanLevel]) + "</b>";
  html += "(PWM " + String(levelDuty[fanLevel]) + "/255)</p>";
  const char* paths[4] = {"/off", "/low", "/mid", "/high"};
  for (int i = 0; i < 4; i++) {
    html += "<p><a href='" + String(paths[i]) + "'><button style='font-size:1.8em;width:60%;padding:15px";
    if (i == fanLevel) html += ";background:orange";   // 目前段速的按鈕上色
    html += "'>" + String(levelName[i]) + "</button></a></p>";
  }
  html += "</body></html>";
  return html;
}

void sendPage() {
  server.send(200, "text/html; charset=utf-8", makePage());
}
void handleRoot() { sendPage(); }
void handleOff()  { setFan(0); sendPage(); }
void handleLow()  { setFan(1); sendPage(); }
void handleMid()  { setFan(2); sendPage(); }
void handleHigh() { setFan(3); sendPage(); }

void setup() {
  Serial.begin(115200);

  // 設定 PWM(esp32 核心 3.x 的寫法)
  ledcAttach(fanPin, PWM_FREQ, PWM_RES);
  // 如果你的 esp32 核心是 2.x,編譯會說找不到 ledcAttach,請改用下面三行:
  // ledcSetup(0, PWM_FREQ, PWM_RES);
  // ledcAttachPin(fanPin, 0);
  // 並把 setFan 裡的 ledcWrite(fanPin, ...) 改成 ledcWrite(0, ...)
  setFan(0);                                   // 一開機先確定風扇是關的

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  Serial.print("connecting");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("http://");
  Serial.println(WiFi.localIP());

  server.on("/", handleRoot);
  server.on("/off", handleOff);
  server.on("/low", handleLow);
  server.on("/mid", handleMid);
  server.on("/high", handleHigh);
  server.begin();
}

void loop() {
  server.handleClient();                       // 不要加 delay
}

步驟

  1. 先不要裝電池。接好所有線後,用三用電表確認 ESP32 GND 和電池盒負極之間是 0Ω(通路)。
  2. 上傳程式,看序列埠拿到 IP。
  3. 裝上電池,手機打開網頁,依序按「低、中、高、關」。
  4. 用手摸 IRLZ44N 的外殼,跑一分鐘高速後應該只是微溫。如果燙手,檢查是不是 G、D、S 接反了。

你應該看到

按「低」馬達慢慢轉(有時候要用手撥一下才會啟動,這是正常的),「中」明顯變快,「高」最快,「關」停下來。網頁上目前段速的按鈕會變橘色。

測試方法(示波器,雙通道)

CH1 探棒鉤 IRLZ44N 的 G(閘極),CH2 鉤 D(汲極),兩個鱷魚夾都接 GND。設定 CH1 2 V/div、CH2 2 V/div、時間 200 µs/div,觸發 CH1 上升緣 1.5V。

CH1 閘極(中速,占空比約 70%)
3.3V ┤ ┌───────┐   ┌───────┐   ┌───────┐
  0V ┼─┘       └───┘       └───┘       └──

CH2 汲極(跟閘極相反:閘極 HIGH 時 MOSFET 導通,汲極被拉到 0V)
4.5V ┤─┐       ┌───┐       ┌───┐       ┌──
  0V ┼ └───────┘   └───────┘   └───────┘
       ◄─────────►
          1 ms(1 kHz)

填表:

段速程式的 PWM 值示波器 CH1 占空比PWM 值 ÷ 255 × 100%頻率CH2 的 HIGH 電壓
低11043%
中18071%
高255100%

舉例:中速 180 ÷ 255 = 0.706,示波器應該讀到大約 70%。CH2 的 HIGH 電壓會接近電池電壓 4.5V,LOW 時接近 0V,而且 CH2 和 CH1 剛好相反:這就是第 9 章學的「開關反相」。如果你在 CH2 上看到馬達關掉瞬間有小小的尖波,那是二極體還沒完全壓下去的部分;沒有二極體的話尖波會高到十幾伏特。

延伸挑戰

  • 把四顆按鈕換成一條滑桿(HTML 的 <input type='range'>),讓風速可以無段調整。提示:路徑可以用 /set?duty=123 這種帶參數的形式,用 server.arg("duty") 讀取。
  • 加上 DHT11,溫度超過 28°C 自動開到中速,形成「智慧風扇」。
  • 加一個定時:按「高」之後 10 分鐘自動關。

實驗 17-4:專題四 ESP32 迷你示波器

目標

用 ESP32 的類比輸入快速取樣,把波形畫在 Arduino IDE 的序列繪圖器上,並自動算出頻率和電壓範圍。訊號源是 UNO 的 tone() 方波,經過分壓器降到 2.5V 以下再送進 ESP32。最後用真的示波器量同一個訊號,比較兩邊的結果,看看你的迷你示波器準不準。

材料

  • ESP32 DevKit V1 ×1
  • Arduino UNO ×1(當訊號產生器)
  • 10kΩ 電阻 ×2
  • 麵包板、杜邦線
  • 示波器(比對用)

接線

 UNO D8 ──[ 10kΩ ]──┬──[ 10kΩ ]── GND
  (tone 方波 0–5V)  │
                    ├────────────── ESP32 GPIO 34(0–2.5V)
                    │
                    └────────────── 示波器 CH1 探棒(比對用)

 UNO GND ──────────── ESP32 GND ──── 示波器鱷魚夾

文字說明:

  • UNO D8 → 10kΩ → 分壓點;分壓點 → 10kΩ → GND。
  • 分壓點 → ESP32 GPIO 34。
  • UNO GND → ESP32 GND,用一條杜邦線直接接。沒接共地,讀值會亂飄。
  • 分壓器把 5V 降成 2.5V,這樣 ESP32 的輸入腳才安全(第 16 章講過)。

程式(UNO:訊號產生器)

// 專題四(UNO 端):產生 440 Hz 方波當訊號源
const int tonePin = 8;

void setup() {
  tone(tonePin, 440);     // 440 Hz,就是鋼琴的 A 音
}

void loop() {
  // 什麼都不用做,tone() 會自己持續輸出
}

程式(ESP32:迷你示波器)

// 專題四(ESP32 端):迷你示波器
// plotMode = true  → 給序列繪圖器畫波形
// plotMode = false → 給序列監控視窗印頻率、電壓等數據
const bool plotMode = true;

const int probePin = 34;           // 只能輸入的腳,適合當探棒
const int SAMPLES  = 400;          // 一次抓 400 個點
uint16_t buffer[SAMPLES];          // 存取樣值(0–4095)

void setup() {
  Serial.begin(115200);
  analogReadResolution(12);        // 12 位元:0–4095
  analogSetAttenuation(ADC_11db);  // 量測範圍最大約 3.3V
  pinMode(probePin, INPUT);
}

void loop() {
  // 1. 盡可能快地連續取樣,並記錄花了多久
  unsigned long t0 = micros();
  for (int i = 0; i < SAMPLES; i++) {
    buffer[i] = analogRead(probePin);
  }
  unsigned long t1 = micros();
  float dtMicros = (float)(t1 - t0) / SAMPLES;     // 每兩個點之間的時間(µs)

  // 2. 找最大值、最小值
  int vMaxRaw = 0, vMinRaw = 4095;
  for (int i = 0; i < SAMPLES; i++) {
    if (buffer[i] > vMaxRaw) vMaxRaw = buffer[i];
    if (buffer[i] < vMinRaw) vMinRaw = buffer[i];
  }
  float vMax = vMaxRaw * 3.3 / 4095.0;             // 換算成伏特
  float vMin = vMinRaw * 3.3 / 4095.0;

  // 3. 算頻率:數波形從下面穿過中線往上的次數
  int mid = (vMaxRaw + vMinRaw) / 2;
  int margin = 100;                                // 留一點空間避免雜訊重複計數
  int crossings = 0;
  int firstCross = -1, lastCross = -1;
  bool wasLow = buffer[0] < mid;
  for (int i = 1; i < SAMPLES; i++) {
    if (wasLow && buffer[i] > mid + margin) {      // 由低變高
      crossings++;
      if (firstCross < 0) firstCross = i;
      lastCross = i;
      wasLow = false;
    } else if (!wasLow && buffer[i] < mid - margin) {
      wasLow = true;
    }
  }
  float freq = 0;
  if (crossings >= 2) {
    // 第一次到最後一次上升之間有 (crossings-1) 個完整週期
    float spanSeconds = (lastCross - firstCross) * dtMicros / 1000000.0;
    freq = (crossings - 1) / spanSeconds;
  }

  // 4. 輸出
  if (plotMode) {
    for (int i = 0; i < SAMPLES; i++) {
      Serial.println(buffer[i] * 3.3 / 4095.0, 2);   // 每行一個電壓值
    }
  } else {
    Serial.print("sample interval: ");
    Serial.print(dtMicros, 1);
    Serial.print(" us   sample rate: ");
    Serial.print(1000000.0 / dtMicros / 1000.0, 1);
    Serial.println(" kHz");
    Serial.print("Vmax: ");
    Serial.print(vMax, 2);
    Serial.print(" V   Vmin: ");
    Serial.print(vMin, 2);
    Serial.print(" V   Vpp: ");
    Serial.print(vMax - vMin, 2);
    Serial.println(" V");
    Serial.print("frequency: ");
    Serial.print(freq, 1);
    Serial.println(" Hz");
    Serial.println("------");
    delay(1000);
  }
}

步驟

  1. UNO 上傳訊號產生器程式。ESP32 上傳迷你示波器程式(plotMode = true)。
  2. 打開 ESP32 的序列繪圖器(115200)。
  3. 看到方波後,把 plotMode 改成 false,重新上傳,改開序列監控視窗,抄下取樣間隔、Vmax、Vmin、頻率。
  4. 把示波器探棒鉤在分壓點,設定 1 V/div、1 ms/div、觸發上升緣 1.2V,用自動量測讀頻率和 Vpp。

你應該看到

序列繪圖器出現一串整齊的方波,高的地方約 2.5V、低的地方約 0V。序列監控視窗印出取樣間隔大約 20–60 µs(不同版本的 esp32 核心差很多)、Vpp 約 2.4–2.5V、頻率在 430–450 Hz 之間。

序列繪圖器畫面(示意)
2.5V ┤  ┌───┐   ┌───┐   ┌───┐   ┌───┐
     │  │   │   │   │   │   │   │   │
  0V ┼──┘   └───┘   └───┘   └───┘   └──
        ◄───────►
      大約 50 個點 = 一個週期

測試方法(示波器比對)

這個專題的測試就是「跟真的示波器打對台」。填表:

項目迷你示波器真示波器差多少
頻率(Hz)
最高電壓(V)
最低電壓(V)
Vpp(V)
取樣間隔(µs)(示波器顯示的取樣率倒數)

算一個例子:如果迷你示波器的取樣間隔是 50 µs,取樣率就是 1 ÷ 0.00005 = 20 kHz,也就是每秒 20,000 個點。440 Hz 的方波一個週期是 2.27 ms = 2270 µs,2270 ÷ 50 ≈ 45 個點,畫出來很清楚。真示波器的取樣率通常是幾百 MHz,是你的幾萬倍,這就是為什麼它能看高頻訊號而你的不行。

一個有趣的實驗:把 UNO 的 tone(tonePin, 440) 改成 tone(tonePin, 5000)。5000 Hz 一個週期只有 200 µs,用 50 µs 取樣只能抓到 4 個點,畫出來會歪七扭八;改成 tone(tonePin, 19000) 的話,甚至會畫出一個完全錯誤的慢波。這叫混疊(Aliasing):取樣太慢的時候看到的是假象。所以經驗法則是,一個週期至少要取 10 個點,你的迷你示波器大概只能相信 1000 Hz 以下的訊號。

延伸挑戰

  • 讓 UNO 每 3 秒換一個頻率(220、440、880 Hz),看迷你示波器算的頻率跟不跟得上。
  • 把分壓點加上第 10 章的 RC 低通濾波器(10kΩ + 100nF),用迷你示波器看方波變成圓角的樣子。
  • 加上「觸發」:在印出 buffer 之前,先找到第一個上升沿的位置,從那裡開始印。這樣每次畫出來的波形起點都一樣,不會左右亂跳,就像真示波器的觸發功能。

實驗 17-5:專題五 反應遊戲與電子琴

目標

用三顆按鈕、三顆 LED 和一顆蜂鳴器做一台雙用途的機器。開機時按住按鈕 1 進入「電子琴模式」:三顆按鈕各發一個音。直接開機則進入「反應遊戲模式」:隨機等幾秒後隨機亮一顆燈並嗶一聲,玩家要盡快按對應的按鈕,機器算出反應時間,五回合後公布最佳成績。用邏輯分析儀獨立量出「燈亮到按下」的時間,驗證程式算的反應時間是對的。

材料

  • Arduino UNO ×1
  • 按鈕開關 ×3
  • LED(紅、綠、藍)各 1
  • 220Ω 電阻 ×3
  • 無源蜂鳴器 ×1
  • 麵包板、杜邦線
  • USB 邏輯分析儀(測試用)

接線

 UNO D2 ───┬── 按鈕 1 ── GND       (內建上拉:沒按 = HIGH,按下 = LOW)
 UNO D3 ───┬── 按鈕 2 ── GND
 UNO D4 ───┬── 按鈕 3 ── GND

 UNO D9  ──[ 220Ω ]──►|── GND    紅 LED(對應按鈕 1)
 UNO D10 ──[ 220Ω ]──►|── GND    綠 LED(對應按鈕 2)
 UNO D11 ──[ 220Ω ]──►|── GND    藍 LED(對應按鈕 3)

 UNO D8 ──( 蜂鳴器 + )
          ( 蜂鳴器 − )── GND

文字說明:

  • 三顆按鈕各一腳接 D2、D3、D4,另一腳接 GND。程式用 INPUT_PULLUP,不用外接電阻。
  • D9 → 220Ω → 紅 LED 長腳,短腳 → GND;D10 綠、D11 藍同樣接法。
  • 蜂鳴器正極(長腳或標 + 的那端)→ D8,負極 → GND。
  • 把三顆按鈕和三顆 LED 由左到右排整齊,紅配按鈕 1、綠配按鈕 2、藍配按鈕 3,玩家才不會搞混。

程式

// 專題五:反應遊戲 + 電子琴(UNO)
// 開機時按住按鈕 1 → 電子琴模式;否則 → 反應遊戲模式

const int buttonPins[3] = {2, 3, 4};      // 三顆按鈕(INPUT_PULLUP,按下 = LOW)
const int ledPins[3]    = {9, 10, 11};    // 三顆 LED
const int buzzerPin     = 8;
const int notes[3]      = {262, 330, 392}; // Do、Mi、Sol 的頻率(Hz)

const int ROUNDS = 5;                     // 一局幾回合
bool pianoMode = false;

// 讀第 i 顆按鈕,按下回傳 true
bool isPressed(int i) {
  return digitalRead(buttonPins[i]) == LOW;
}

// 等所有按鈕都放開
void waitAllReleased() {
  while (isPressed(0) || isPressed(1) || isPressed(2)) {
    delay(5);
  }
  delay(30);                              // 等彈跳結束
}

// 三顆 LED 全關
void allLedsOff() {
  for (int i = 0; i < 3; i++) digitalWrite(ledPins[i], LOW);
}

// 嗶一聲:頻率、長度
void beep(int freq, int ms) {
  tone(buzzerPin, freq, ms);
  delay(ms);
}

void setup() {
  Serial.begin(9600);
  for (int i = 0; i < 3; i++) {
    pinMode(buttonPins[i], INPUT_PULLUP);
    pinMode(ledPins[i], OUTPUT);
  }
  pinMode(buzzerPin, OUTPUT);
  randomSeed(analogRead(A0));             // 用沒接東西的 A0 的雜訊當亂數種子

  delay(50);
  pianoMode = isPressed(0);               // 開機時按住按鈕 1 → 電子琴
  if (pianoMode) {
    Serial.println("piano mode");
    for (int i = 0; i < 3; i++) {         // 三顆燈依序閃一下表示進入電子琴
      digitalWrite(ledPins[i], HIGH);
      beep(notes[i], 150);
      digitalWrite(ledPins[i], LOW);
    }
    waitAllReleased();
  } else {
    Serial.println("reaction game: press the button that lights up");
    beep(880, 200);
  }
}

// ===== 電子琴模式 =====
void pianoLoop() {
  bool anyPressed = false;
  for (int i = 0; i < 3; i++) {
    if (isPressed(i)) {
      tone(buzzerPin, notes[i]);          // 按著就一直響
      digitalWrite(ledPins[i], HIGH);
      anyPressed = true;
    } else {
      digitalWrite(ledPins[i], LOW);
    }
  }
  if (!anyPressed) noTone(buzzerPin);     // 全放開就安靜
  delay(10);
}

// ===== 反應遊戲模式 =====
void playOneRound(int roundNumber, unsigned long &bestTime) {
  allLedsOff();
  waitAllReleased();

  // 隨機等 1–4 秒,期間偷按就算犯規
  unsigned long waitMs = random(1000, 4000);
  unsigned long waitStart = millis();
  while (millis() - waitStart < waitMs) {
    if (isPressed(0) || isPressed(1) || isPressed(2)) {
      Serial.println("too early!");
      beep(150, 400);                     // 低沉的犯規聲
      waitAllReleased();
      waitStart = millis();               // 重新等
    }
  }

  // 隨機亮一顆燈並嗶一聲,同時開始計時
  int target = random(0, 3);
  digitalWrite(ledPins[target], HIGH);
  tone(buzzerPin, 880, 80);
  unsigned long startTime = millis();

  // 等玩家按(最多等 3 秒)
  int pressed = -1;
  while (millis() - startTime < 3000) {
    for (int i = 0; i < 3; i++) {
      if (isPressed(i)) { pressed = i; break; }
    }
    if (pressed >= 0) break;
  }
  unsigned long reaction = millis() - startTime;
  digitalWrite(ledPins[target], LOW);

  Serial.print("round ");
  Serial.print(roundNumber);
  Serial.print(": ");
  if (pressed < 0) {
    Serial.println("timeout");
    beep(150, 400);
  } else if (pressed != target) {
    Serial.println("wrong button");
    beep(150, 400);
  } else {
    Serial.print(reaction);
    Serial.println(" ms");
    beep(1200, 100);
    if (reaction < bestTime) bestTime = reaction;   // 更新最佳成績
  }
  waitAllReleased();
  delay(500);
}

void gameLoop() {
  unsigned long bestTime = 99999;
  for (int r = 1; r <= ROUNDS; r++) {
    playOneRound(r, bestTime);
  }
  Serial.print("best: ");
  Serial.print(bestTime);
  Serial.println(" ms");
  // 結束動畫:三顆燈一起閃三下
  for (int k = 0; k < 3; k++) {
    for (int i = 0; i < 3; i++) digitalWrite(ledPins[i], HIGH);
    beep(notes[k], 150);
    allLedsOff();
    delay(150);
  }
  delay(2000);                            // 兩秒後自動開始下一局
}

void loop() {
  if (pianoMode) {
    pianoLoop();
  } else {
    gameLoop();
  }
}

步驟

  1. 接好線,先上傳程式,直接開機(不按按鈕)。
  2. 打開序列埠監控視窗(9600),等燈亮就按對應按鈕,看印出來的毫秒數。玩完五回合會印「best」。
  3. 按一下 UNO 的 RESET 鍵,同時按住按鈕 1 不放,三顆燈依序閃並發出 Do Mi Sol,就進入電子琴模式。

你應該看到

反應遊戲:等 1–4 秒後隨機一顆燈亮、嗶一聲,按對會印出像「round 1: 243 ms」的反應時間,按錯或超過 3 秒會低鳴一聲。人類的反應時間大約 200–300 毫秒;偷按會被抓到「too early!」。電子琴:按哪顆按鈕就亮哪顆燈、響哪個音,可以彈出簡單的旋律。

測試方法(邏輯分析儀 + 示波器)

邏輯分析儀:CH0 → D9(紅 LED)、CH1 → D10、CH2 → D11、CH3 → D2(按鈕 1)、CH4 → D3、CH5 → D4、GND → UNO GND。PulseView 取樣率 100 kHz、取樣 10 秒,按「執行」後開始玩一回合。

CH0 紅 LED ───────┐       ┌─────────────────────────────────┐
                  └───────┘                                 └────
CH3 按鈕 1 ───────────────────────────────┐ ┌┐┌───────┐
                                          └─┘└┘        └─────────
                  ◄───────────────────────►
                     燈亮到按下 = 反應時間(用游標量)

在 PulseView 用兩個游標夾住「LED 由低變高」和「按鈕由高變低的第一個下降緣」,讀出時間差,跟序列埠印的反應時間比對。順便放大看按鈕那一段,你會看到第 11 章講的彈跳:按下的瞬間有好幾個很短的上下跳動。

示波器:探棒鉤 D8,鱷魚夾接 GND,1 V/div、1 ms/div,進入電子琴模式按住按鈕 1,量頻率。

項目程式設定/印出儀器量到差多少
反應時間(毫秒)序列埠印的邏輯分析儀游標
按鈕彈跳持續時間(無)
按鈕 1 的音高(Hz)262示波器頻率
按鈕 3 的音高(Hz)392

程式算的反應時間和邏輯分析儀量的應該只差幾毫秒。差的來源是 loop() 裡檢查按鈕的那個 for 迴圈需要一點點時間,以及彈跳的第一下和程式認定的那一下可能不是同一下。

延伸挑戰

  • 加入 OLED(第 15 章的接法,UNO 的 SDA=A4、SCL=A5)顯示每回合的反應時間和最佳成績。
  • 做成「記憶遊戲」:機器依序亮 3 顆燈,玩家要照順序按;每成功一次就多加一顆。
  • 雙人對戰:按鈕 1 和按鈕 3 各屬於一個玩家,中間的綠燈亮時比誰先按。
  • 電子琴加入「和弦」:同時按兩顆按鈕時,用更高頻率的 tone() 表示(蜂鳴器一次只能發一個音,想想怎麼取捨)。

收尾:專題報告與上台展示

作品會動只是一半,另一半是讓別人在三分鐘內看懂你做了什麼、為什麼成功。

專題報告格式(兩到四頁)

 1. 題目與一句話說明      例:「智慧夜燈:天暗自動漸亮的 LED 燈」
 2. 問題                  誰、在什麼情況、需要什麼
 3. 規劃                  輸入 → 處理 → 輸出 表格 + 材料清單
 4. 接線圖                ASCII 或手繪,標清楚每個腳位
 5. 程式說明              不要貼全部程式,只貼最關鍵的 5–10 行並解釋
 6. 測試與量測數據        ★至少一張示波器或邏輯分析儀的畫面(拍照)
                          ★至少一張填好的量測表格
                          ★程式設定值 vs 儀器量測值的比較
 7. 遇到的問題與解法      列 2–3 個真的卡住過的地方
 8. 延伸與反思            如果有更多時間會改什麼

第 6 點是整份報告的核心,也是本章每個專題都附「測試方法」的原因。「我的夜燈 PWM 頻率是 490 Hz,示波器量到 489.6 Hz」這一句,比「夜燈運作正常」有說服力一百倍。

第 7 點很多人會跳過,覺得寫出問題很丟臉。其實評審最想看的就是這段,因為它證明你真的動手做過,而不是抄來的。

上台展示要點(三分鐘)

  1. 第一句話講問題,不要講技術:「晚上起床找燈的開關很麻煩,所以我做了……」比「我用 Arduino UNO 的 PWM……」好得多。
  2. 現場操作一定要成功:上台前一小時再完整跑一次;帶備用電池、備用 USB 線;手機先連好 Wi-Fi 並把網頁開在畫面上。ESP32 的專題要事先確認場地的 Wi-Fi 是 2.4 GHz,不確定就帶自己的手機熱點。
  3. 用一張波形圖證明:把示波器畫面拍下來放在投影片或海報上,指著它說「這裡可以看到占空比從 0 變到 100%」。
  4. 講一個卡住的故事:「一開始馬達關掉時 MOSFET 會發燙,後來加了二極體就好了。」這種故事比任何技術細節都讓人記得住。
  5. 準備被問的三個問題:「為什麼選這個元件?」「如果要用電池可以撐多久?」「下一步想加什麼?」照本章的內容你都答得出來。
  6. 時間控制:問題 30 秒、作品示範 90 秒、量測證據 30 秒、問題與延伸 30 秒。練習時用手機計時。

用儀器看看

本章的儀器操作已經分散在五個專題的「測試方法」裡。這裡整理成一張表,方便你規劃測試,也方便你在報告裡寫清楚設定:

專題儀器接哪裡設定要量的東西預期值
一 智慧夜燈示波器CH1→UNO D92 V/div、500 µs/div、上升緣 2.5V占空比、頻率占空比隨光線 0–100%,約 490 Hz
二 氣象站邏輯分析儀CH0→GPIO 21、CH1→GPIO 222 MHz、I2C 解碼器位址、封包間隔、SCL 頻率0x3C、2 秒、約 100 kHz
三 風扇示波器CH1→閘極、CH2→汲極2 V/div、200 µs/div、上升緣 1.5V三段占空比、反相、頻率43%/71%/100%,1 kHz
四 迷你示波器示波器CH1→分壓點1 V/div、1 ms/div、上升緣 1.2V頻率、Vpp,與 ESP32 比對440 Hz、約 2.5V
五 反應遊戲邏輯分析儀+示波器LA:LED 與按鈕腳;示波器→D8100 kHz、10 秒;1 V/div、1 ms/div反應時間、彈跳、音高與序列埠相符;262/330/392 Hz

把示波器畫面變成報告裡的證據

示波器通常有「儲存畫面」的功能(存到 USB 隨身碟),沒有的話用手機拍也可以。拍的時候請確認畫面上看得到:V/div、s/div、觸發設定、以及自動量測的數字。報告裡每一張波形圖旁邊都要寫一句「這張圖證明了什麼」,例如:

圖 3:風扇中速時閘極波形。占空比 70.2%、頻率 1.00 kHz,與程式設定的 180/255 = 70.6% 一致。

雙通道的共地提醒

專題三和專題四都用到兩塊板子或兩個通道。兩支探棒的鱷魚夾都要夾 GND,而且兩塊板子的 GND 要用杜邦線接在一起。鱷魚夾夾錯地方(例如夾到電池正極)會造成短路,這是本書從第 6 章一直強調的規矩。

常見錯誤與除錯

  • 夜燈 LED 一直全亮或一直全暗,不會變 → darkValue、brightValue 沒有依教室光線調整 → 看序列繪圖器的 light 值,重新填這兩個數字。
  • 夜燈 LED 突然跳亮,不是漸亮 → fadeStepDelay 太小,或程式直接 analogWrite(target) 沒有走一步一步 → 確認 currentBrightness 每圈只加減 1。
  • 氣象站長條圖一直只有一條 → HISTORY_INTERVAL 是 60 秒,要等很久 → 展示時改成 5000。
  • 氣象站最低溫顯示 100 → 開機後 DHT11 第一次讀取失敗,tempMin 沒更新 → 等 2 秒後自然會更新;或在 setup() 裡多等 1.5 秒再讀。
  • 風扇按「低」不轉,按「中」才轉 → 小馬達啟動需要較高電壓 → 把 levelDuty[1] 從 110 調到 130 或 150。
  • 風扇一開就 ESP32 重開機 → 馬達電流從 ESP32 供電 → 馬達一定要用獨立電池盒,只有 GND 跟 ESP32 共接。
  • 編譯錯誤 ledcAttach was not declared → esp32 核心版本是 2.x → 改用程式註解裡的 ledcSetup、ledcAttachPin 三行寫法。
  • MOSFET 發燙 → G、D、S 接反,或閘極沒接 10kΩ 下拉讓它半開半關 → 對照腳位圖重接,確認 10kΩ 在閘極和 GND 之間。
  • 迷你示波器波形亂跳、全部貼在最高或最低 → 沒共地,或分壓器接錯讓 GPIO 34 吃到 5V → 先拔掉訊號,用三用電表確認分壓點是 2.5V 左右,確認 UNO GND 接到 ESP32 GND。
  • 迷你示波器頻率算出 0 → 訊號太小或沒接,crossings 不到 2 → 檢查 UNO 有沒有在 tone();把 margin 從 100 改小一點試試。
  • 序列繪圖器波形只看到一小段 → 繪圖器一次顯示的點數有限 → 把 SAMPLES 改成 100,或在繪圖器設定裡調大顯示點數。
  • 反應遊戲一開機就說 too early → 按鈕接錯成「沒按是 LOW」,或 pinMode 忘了 INPUT_PULLUP → 用三用電表量 D2 沒按時應該是 5V。
  • 反應遊戲每次亮的燈都一樣 → randomSeed(analogRead(A0)) 的 A0 接了東西,雜訊不夠 → 確認 A0 空接;或改用 randomSeed(micros()) 在第一次按鈕後呼叫。
  • 電子琴按兩顆按鈕只響一個音 → 蜂鳴器用 tone() 一次只能一個頻率 → 這是正常的,看延伸挑戰。
  • 邏輯分析儀量的反應時間和序列埠差很多(超過 20 ms) → 游標夾的是彈跳的最後一下,不是第一下 → 放大看按鈕訊號,把游標移到第一個下降緣。

想一想

  1. 專題一的夜燈從全暗到全亮花多少時間?如果想改成 1 秒,要改哪個數字、改成多少?
參考答案

255 步 × 10 毫秒 = 2550 毫秒 ≈ 2.5 秒。想改成 1 秒,把 fadeStepDelay 改成 4(255 × 4 = 1020 毫秒)。

  1. 專題三的風扇,如果把 PWM_FREQ 從 1000 改成 100,示波器上會看到什麼不同?馬達會有什麼變化?
參考答案

示波器上方波的週期從 1 ms 變成 10 ms(要把時間調到 2 ms/div 才看得到幾個週期)。占空比不變,所以平均轉速差不多,但 100 Hz 在人耳聽得到的範圍,馬達會發出明顯的嗡嗡聲,而且低速時可能一頓一頓的。

  1. 專題四的迷你示波器,取樣間隔是 40 µs。它最高大概能可靠地看到幾 Hz 的方波?請用「一個週期至少 10 個點」的法則算。
參考答案

10 個點 × 40 µs = 400 µs 是最短的可靠週期,頻率 = 1 ÷ 0.0004 = 2500 Hz。超過這個頻率,波形會變形,再高就會出現混疊的假波形。

  1. 專題五用邏輯分析儀量到的反應時間比序列埠印的少了 3 毫秒。可能的原因是什麼?這算是程式有錯嗎?
參考答案

程式用 millis() 計時,而 millis() 只有 1 毫秒的解析度;再加上 loop() 裡用 for 迴圈輪流檢查三顆按鈕,從按下到程式「發現」會慢一點點;彈跳也可能讓儀器和程式認定的「按下瞬間」不同。差 3 毫秒對反應遊戲來說完全可以接受,不算錯,但在報告裡要誠實寫出來並解釋原因。

  1. 你想把專題二的氣象站改成放在陽台用電池跑一個月。列出至少三件需要改動的事。
參考答案

(任三個)用第 16 章的深度睡眠,每 5–10 分鐘醒來一次而不是一直開著 Wi-Fi;OLED 只在醒來那幾秒顯示,或拿掉 OLED;資料不能只存在 ESP32 的記憶體裡(睡覺會不見),要用 RTC_DATA_ATTR 或送到網路上(例如 MQTT);網頁伺服器在睡覺時無法回應,要改成 ESP32 主動把資料送出去;做防水外殼;用 3 顆 3 號電池接 VIN(4.5V 對 DevKit V1 的穩壓器來說有點低,可能要 4 顆)。

  1. 開放挑戰:從五個專題裡挑兩個,把它們合併成一個新作品,寫下那句「誰、在什麼情況、需要什麼」的問題描述,和「輸入 → 處理 → 輸出」表格。
參考答案

範例:合併專題二和專題三:「夏天午睡的人,需要一台房間太熱時會自己開、涼了會自己關的風扇。」輸入:DHT11 溫度;處理:高於 28°C 開中速、高於 30°C 開高速、低於 26°C 關閉,並在網頁上顯示目前溫度與段速、允許手動覆蓋;輸出:IRLZ44N 驅動馬達、OLED 顯示狀態。另一個例子是合併一和五:天黑後用按鈕反應遊戲當「晚安儀式」,玩完燈才慢慢暗下來。答案不唯一,重點是問題描述要具體,表格的每一格都要是你接過的元件。

本章小結

  • 專題六步驟:問題 → 規劃 → 接線 → 程式 → 測試 → 展示;測試不過就回頭改,這是正常流程不是失敗。
  • 規劃時把題目拆成「輸入 → 處理 → 輸出」三格,每一格都填上你在前面章節接過的元件。
  • 接線先接電源再接輸入最後接輸出,每接一段就用三用電表量一次;程式先寫最小可動版本,每加一段就上傳一次。
  • 專題程式的骨架:常數放最上面、小函式各做一件事、loop() 用 millis() 排程不用長 delay()。
  • 五個專題各自用儀器驗證了一件事:PWM 占空比隨光線變化(示波器)、I2C 位址 0x3C 與封包間隔(邏輯分析儀)、三段占空比與汲極反相(雙通道示波器)、迷你示波器 vs 真示波器的頻率與 Vpp(示波器比對)、反應時間與按鈕彈跳(邏輯分析儀)。
  • 取樣率決定你看得到多快的訊號:一個週期至少 10 個點,太慢會混疊出假波形。
  • 馬達一定要獨立供電、加飛輪二極體、閘極加下拉電阻;ESP32 的 GPIO 34 吃 5V 訊號前一定要分壓。
  • 報告的核心是「程式設定值 vs 儀器量測值」的比較;上台第一句講問題不講技術,現場示範上台前一小時再跑一次。

名詞表

中文英文一句話解釋
專題Project把多個元件和程式組合起來解決一個具體問題的作品。
最小可動版本Minimum working version功能最少但已經能跑的第一版程式,之後再一段一段加。
輸入/處理/輸出Input / Process / Output規劃專題時把題目拆成感測、判斷、動作三格的方法。
占空比Duty cyclePWM 方波裡 HIGH 所占的時間比例,決定 LED 亮度或馬達轉速。
閘極下拉電阻Gate pull-down resistor接在 MOSFET 閘極與 GND 之間的電阻,確保沒訊號時 MOSFET 關閉。
飛輪二極體Flyback diode跨接在馬達兩端的二極體,吸收關掉時的反電動勢尖波。
共地Common ground兩塊板子或儀器的 GND 接在一起,讓它們有相同的 0V 基準。
取樣率Sampling rate每秒取幾個點,決定能看到多快的訊號。
取樣間隔Sampling interval兩個取樣點之間的時間,是取樣率的倒數。
混疊Aliasing取樣太慢時看到的假波形。
觸發Trigger示波器或程式從波形固定的位置(例如上升緣)開始畫,讓畫面不亂跳。
歷史紀錄History buffer用陣列存最近 N 筆資料,滿了就擠掉最舊的。
亂數種子Random seed讓 random() 每次開機產生不同序列的起始值。
反應時間Reaction time從刺激出現到做出動作的時間,人類大約 200–300 毫秒。
量測證據Measurement evidence報告裡用儀器數據證明作品照設計運作的部分。