---
# System prepended metadata

title: "用 C++ 和 JS 打造一個軍事風 Arduino 雷達 \U0001F4E1"
tags: [C++, JavaScript, Arduino, Frontend, IoT]

---

# 用 C++ 和 JS 打造一個軍事風 Arduino 雷達 📡

{%vimeo 1010846093 %}

:::info
[▶ Github Repo 點我 🥊](https://github.com/unicornGL/arduino-radar)
:::


## 前言
轉職以來都是寫前端的我，一直覺得硬體是一個酷東東。跟積木一樣拼一拼（？）再寫寫扣就能讓虛擬程式跟實體世界連結，多麼讓人著迷🥺 雖然高中生活科技有上過相關的課程，但那時候我們只顧著在桌子底下打魔物，根本沒時間接電路（單押XD）。

年初的時候買了一盒 Arduino 入門組，裡面有一塊 UNO 板跟各種套件，根本跟拿到聖誕禮物一樣開心。但因為要學一個新的語言，所以後來忙起來就沒繼續碰。直到看了曾英綺（17King）的[《IoT沒那麼難！新手用JavaScript入門做自己的玩具！》](https://www.tenlong.com.tw/products/9789864345328)之後，發覺用 JS 竟然就可以寫 IoT 了，我還不大玩一波！

不過理想雖豐滿但現實挺骨感，Johnny-Five 畢竟只是一個套件而硬體百百種，總會不支援或者是用起來卡卡的地方。經過一番撞牆和摸索之後索性直接寫 C++，邊用邊查文件倒也不慢。所以這個專案硬體的部分會使用 C++、前後端則使用 JS，瞭解原理之後其實很簡單的。

跟我一起動手，玩出一個酷酷雷達吧🔧

## 整體架構
```mermaid
graph TD
    A[硬體: Arduino] -->|USB 序列通訊| B[後端: Node.js 伺服器]
    B -->|Socket.IO| C[前端: 網頁瀏覽器]

    subgraph 硬體層
    A -->|控制| D[伺服馬達]
    D -->|角度資料| A
    E[超音波感測器] -->|距離資料| A
    end

    subgraph 後端層
    B -->|讀取序列資料| F[SerialPort.IO]
    F -->|解析資料| G[資料處理]
    G -->|發送資料| H[Socket.IO 伺服器]
    H -->|即時傳輸| B
    end

    subgraph 前端層
    C --> I[Socket.IO 客戶端]
    I -->|接收資料| J[D3.js]
    J -->|更新資料| K[SVG 繪製]
    K -->|渲染| C
    end
```
簡單來說就是 Arduino 接收伺服馬達和超音波感測器的資料，藉由 USB 傳到電腦。
後端經由 [SerialPort.IO](https://serialport.io) 讀取並解析資料後將其由 [Socket.IO](https://socket.io) 傳到前端，最後用 [D3.js](https://d3js.org) 繪製出來。

## 硬體

### 硬體需求與介紹

- Arduino UNO
- 伺服馬達（SG90）
- 超音波感測器（HC-SR04）
- 跳線 / 杜邦線

#### Arduino UNO

Arduino UNO 應該是入門最好用的 Arduino 開發板了。簡單介紹一下，Arduino 是一款開源的微控制器開發板，可以運行一些相對比較簡單的應用程式。需要透過 USB 把程式燒錄進去才能用，其他像是音樂、網路、藍牙、GPS、資料儲存等功能需要額外的擴展板去實現。適合做一些與電路、傳感器等相關而不需要太多複雜計算的專案，如自走避障車、四軸飛行器、時鐘、溫濕度計等。

另一個常拿來對比的是樹莓派（Raspberry Pi），這是一款微型單板電腦，有自己的作業系統，所以更適合處理軟體類的工作，如遊戲虛擬機、網頁伺服器、機器人、家庭智能控制系統等功能與成本較高的專案。

下圖是 Arduino UNO 的 pin 表，[官方文件及大圖請點我](https://docs.arduino.cc/hardware/uno-rev3/)

![Arduino-UNO-full-pinout](https://hackmd.io/_uploads/SJcvKrYTR.png)

#### 伺服馬達

伺服馬達（Servo Motor，簡稱 Servo）是一種能精確控制角度、速度和加速度的電動機。在接收到控制信號（PWM）之類會比較當前位置和目標位置，再驅動電機旋轉直到到達目標位置後停止。主要特點是能夠快速抵達精確定位並維持，但有最大最小旋轉角度的限制。常用於機械關節控制、自動化設備的定位操控（像這個專案的雷達就是）、遙控飛機的機翼控制等。每款伺服馬達能轉動的角度範圍都不一樣，如果超過就可能會造成損害。另外如果要同時使用多個馬達就需要外接電供。

#### 超音波感測器

超音波感測器（Ultrasonic Sensor）是用超聲波來感測距離的儀器，公式是 *距離 =（聲波速度 * 往返時間）/ 2*。基本上不受光線影響（但會受到溫濕度影響），但不容易反射（如絨毛）或太過傾斜的表面會導致測量失準。另外，也有最大和最小測量距離限制。通常用於避障、倒車雷達、液位測量等功能。

#### 跳線 / 杜邦線

用來連接開發板和電子元件的線，比起焊接方便一百倍但穩定度較差，通常拿來做原型開發或簡單的專案使用。有公對公、公對母、母對母三種，並排在一起的叫做排線。

### 接線圖

![arduino-radar_breadboard](https://hackmd.io/_uploads/H1sKhUFaA.png)

### 電路圖

![arduino-radar_wiring](https://hackmd.io/_uploads/HJyip8KT0.png)

### 程式

#### 使用套件

- [NewPing](https://bitbucket.org/teckel12/arduino-new-ping/wiki/Home)：超音波感測器用。
- [Servo](https://www.arduino.cc/reference/en/libraries/servo/)：官方的伺服馬達套件。

#### 程式說明

💾 [arduino-radar.ino](https://github.com/unicornGL/arduino-radar/blob/main/arduino-radar.ino)

```cpp
#include <NewPing.h>
#include <Servo.h>

constexpr int TRIG_PIN = 12;                // 超音波感測器的觸發腳
constexpr int ECHO_PIN = 11;                // 超音波感測器的回聲腳
constexpr int MAX_DETECT_DISTANCE = 200;    // 最大偵測距離（公分）
constexpr int READINGS_COUNT = 5;           // 用於平均計算的讀數數量
constexpr int MAX_CONSECUTIVE_ZEROS = 3;    // 允許的最大連續零讀數
constexpr int SERVO_PIN = 9;                // 伺服馬達的控制腳
constexpr int MIN_ANGLE = 15;               // 伺服馬達的最小旋轉角度
constexpr int MAX_ANGLE = 165;              // 伺服馬達的最大旋轉角度
constexpr int CENTER_ANGLE = 90;            // 伺服馬達的中心角度
constexpr int SCAN_SPEED = 1;               // 掃描速度（每次旋轉的角度）
constexpr unsigned long SCAN_INTERVAL = 40; // 掃描間隔（毫秒）
```
- `constexpr` 在編譯時就會被計算且不能在運行時被修改，故可以用來定義常量（編譯時就需要）。
- `unsigned` 表示無符號整數（不能表示負數），相較有符號整數可以表示更大的正數範圍，常用於表示大小或索引。
- 每次單程掃描一遍耗時 (`MAX_ANGLE` - `MIN_ANGLE`) * `SCAN_INTERVAL` / `SCAN_SPEED` = (165 - 15) * 40 / 1 = 6（秒），在前端繪圖時會用到。
- 這邊用到的 pin 可以隨意調整。
- Servo 若能轉到 0 和 180 度且不會晃動，`MAX_ANGLE` 和 `MIN_ANGLE` 可以調整。

```cpp
// 距離過濾器
class DistanceFilter {
private:
  int readings[READINGS_COUNT];
  int readIndex = 0;
  int total = 0;
  float filteredDistance;
  int lastValidReading = MAX_DETECT_DISTANCE;
  int consecutiveZeroCount = 0;
  float alpha = 0.5;  // 指數移動平均的平滑因子

public:
   // 預設初始狀態並未偵測到物體
  DistanceFilter() {
    for (int i = 0; i < READINGS_COUNT; i++) {
      readings[i] = MAX_DETECT_DISTANCE;
    }
    total = MAX_DETECT_DISTANCE * READINGS_COUNT;
    filteredDistance = MAX_DETECT_DISTANCE;
  }

  // 過濾函數：平滑化讀數
  float filter(int currentReading) {
    int adjustedReading = handleZeroReading(currentReading);
    
    // 簡單移動平均（SMA）
    total -= readings[readIndex];
    readings[readIndex] = adjustedReading;
    total += readings[readIndex];
    readIndex = (readIndex + 1) % READINGS_COUNT;
    float sma = total / READINGS_COUNT;
    
    // 指數移動平均（EMA）
    filteredDistance = alpha * sma + (1 - alpha) * filteredDistance;
    
    return filteredDistance;
  }

private:
  // 處理零讀數
  int handleZeroReading(int reading) {
    if (reading == 0) {
      consecutiveZeroCount++;
      if (consecutiveZeroCount >= MAX_CONSECUTIVE_ZEROS) {
        lastValidReading = MAX_DETECT_DISTANCE;
      }
    } else {
      consecutiveZeroCount = 0;
      lastValidReading = reading;
    }
    return lastValidReading;
  }
};

DistanceFilter distanceFilter;
```
- 需要距離過濾器的原因
    - 雜訊干擾：超音波感測器在測量過程中可能受到空氣流動、溫濕度變化、物體表面特性等因素影響，導致讀數出現波動或誤差。
    - 瞬時錯誤：有時感測器可能會輸出一些明顯錯誤的讀數，比如突然的零值或異常高值。
    - 系統穩定性：在實際應用中，我們需要穩定、可靠的距離數據而非原始讀數。
- 這邊使用兩種方式平滑化讀數
    - 簡單移動平均（SMA）：減少隨機誤差的影響。
    - 指數移動平均（EMA）
        - alpha 決定新值對過濾結果的影響，影響響應速度和平滑度。
        - 較大的 alpha 值使過濾器更快響應新的變化，較小的 alpha 值則產生更平滑的輸出。
- 零讀數的處理
    - 如果連續零讀數大於或等於 `MAX_CONSECUTIVE_ZEROS` 便假設物體超出偵測範圍，將結果設為 `MAX_DETECT_DISTANCE`。
    - 如果連續零讀數小於 `MAX_CONSECUTIVE_ZEROS` 則視為偶發情況，使用最後一個非零讀數作為結果。

```cpp
int currentAngle = CENTER_ANGLE;
ScanDirection currentDirection = ScanDirection::COUNTER_CLOCKWISE;
unsigned long lastScanTime = 0;

void setup() {
    myServo.attach(SERVO_PIN);
    Serial.begin(9600);
    myServo.write(CENTER_ANGLE);
    delay(1000);
}

void loop() {
    unsigned long currentTime = millis();
    if (currentTime - lastScanTime >= SCAN_INTERVAL) {
        rotateRadar();
        measureAndReportDistance();
        lastScanTime = currentTime;
    }
}

// ...省略部分內容

void measureAndReportDistance() {
    float distance = distanceFilter.filter(sonar.ping_cm());
    Serial.print(currentAngle);
    Serial.print(",");
    Serial.println(distance);
}
```
- Arduino 程式（.ino）中一定會出現的兩個函數
    - `setup()`
        - 目的：
            - 初始化變數、傳感器或其他硬體。
            - 設置 pin 模式（輸入/輸出）。
            - 啟動序列埠通信。
        - 執行時機：Arduino 開機或重置後只執行一次。
    - `loop()`
        - 目的：執行主程式邏輯。
        - 執行時機：在 `setup()` 執行完後會不斷重複執行直到斷電或重置。
- `Serial.begin()` 可以設定 baud rate。這是 Arduino 與外部設備通信的關鍵參數，跟傳輸速度有關。
- 用 `Servo.write()` 控制 Servo 的目標角度。
- 在 `loop()` 裡面我用了非阻塞延遲的寫法，允許程式在等待特定時間間隔時仍能執行其他任務，且未來添加新的任務時也比較容易。
    - 與 `delay()` 不同，這種方法不會暫停整個程式的執行，故不會錯過重要的事件或中斷。
    - 使用減法比較的方式，使其即使長時間運行，也不會因為 `millis()` 溢出而出錯。
    - 跟 JS 的 throttle 有些相似，不同之處在於這裡的非阻塞延遲是用於定期任務、throttle 主要是用於限制高頻事件的處理。
- Arduino IDE 的 Serial Monitor 中可以看到 `measureAndReportDistance()` 輸出的資料，這邊我們使用 JS 的 [SerialPort.IO](https://serialport.io) 去讀取。
    - 記得把持續置底打開，這樣才會一直顯示最新資料）。
    - 行末設置建議選 Both NL & CR（會在每行數據後添加換行符和回車符（`\r\n`）），這是比較通用的配置。

## 後端

### 程式

#### 使用套件

- [Express](https://expressjs.com)：跟 Node.js 就是哥倆好一對寶。
- [Socket.IO](https://socket.io)：前後端通信用。
- [SerialPort.IO](https://serialport.io)：讀取並解析 Arduino 資料用。

#### 程式說明

💾 [server/index.js](https://github.com/unicornGL/arduino-radar/blob/main/server/index.js)

```javascript
// 序列埠通訊設置
const port = new SerialPort({
  path: "/dev/cu.usbmodem1101",
  baudRate: 9600,
})

// 創建一個 ReadlineParser 來處理接收到的數據
const parser = port.pipe(new ReadlineParser({ delimiter: "\r\n" }))

parser.on("data", (data) => {
  const [angle, distance] = data.split(",").map(Number)
  io.emit("radarData", { angle, distance })
})
```
- 序列埠通訊的設定
    - `path` 在不同作業系統有不同的查找方式。
        - macOS：在 terminal 輸入 `ls /dev/cu.*` 列出目前所有的序列埠設備。Arduino 設備通常顯示為 `/dev/cu.usbmodem1101` 或類似格式。
        - linux：在 terminal 輸入 `ls /dev/ttyACM*` 或 `ls /dev/ttyUSB*` 列出目前所有的序列埠設備。Arduino 設備通常顯示為 `/dev/ttyACM0` 或類似格式。
        - windows
            1. 打開裝置管理員（Device Manager）。
            2. 查看「埠 (COM & LPT)」下的列表。
            3. Arduino 設備通常顯示為 "Arduino Uno (COMx)"，其中 x 是一個數字。
            4. 在代碼中使用 `COM3` 或類似的格式。
    - `baudRate` 要跟 Arduino 程式中 `Serial.begin()` 的值一致。
- 由於 `Serial.println()` 會在末尾添加 `'\r\n'`，所以 ReadlineParser 要添加對應的 delimiter（分隔符）。

## 前端

### 程式

#### 使用套件

- [Socket.IO](https://socket.io)：前後端通信用。
- [D3.js](https://d3js.org)：好用的繪圖 / 數據可視化套件。

#### 程式說明

💾 [public/script.js](https://github.com/unicornGL/arduino-radar/blob/main/public/script.js)

```javascript
const START_ANGLE = 0      // 雷達圖的最小角度
const END_ANGLE = 180      // 雷達圖的最大角度
const SCAN_DURATION = 6000 // 掃描持續時間(毫秒)

const svgElement = document.getElementById("radar")
const svgWidth = svgElement.width.baseVal.value
const svgHeight = svgElement.height.baseVal.value
const centerX = svgWidth * 0.5    // SVG 中心位置 X 坐標
const centerY = svgHeight * 0.745 // SVG 中心位置 Y 坐標
const r = Math.min(svgWidth, svgHeight) * 0.5 // 雷達圖半徑
```
- 這邊的 `START_ANGLE` 跟 `END_ANGLE` 是畫雷達圖用的，跟 Servo 的最大最小旋轉角度沒有直接關係。
- 還記得 Arduino 那邊提過的單程掃描時間嗎？就是這邊的 `SCAN_DURATION`。

```javascript
// 工具們
const rotateBearing = (angle) => angle - 90 // 旋轉方位角
const toRads = (angle) => (rotateBearing(angle) * Math.PI) / 180 // 角度轉弧度
const getZuluTime = () =>
  new Date().toUTCString().slice(17, 22).replace(":", "") + "Z" // 獲取Zulu時間

// 建立極座標網格
const grid = svg
  .append("g")
  .attr("transform", `translate(${centerX},${centerY})`)
```
- 簡單介紹一下極座標
    - 組成元素
        - 極點：坐標系的中心點。
        - 極軸：從極點出發的水平線，通常指向右側，類似於笛卡爾坐標系的 x 軸。
        - 徑向距離 $(r)$：從極點到指定點的距離。
        - 極角 $(\theta)$：從極軸到徑向線的角度。
    - 表示方法
        - 點在極坐標中表示為 $(r, \theta)$，其中 $r$ 是距離，$\theta$ 是角度。
        - 角度通常以弧度（radians，簡稱 rad）表示，但有時也使用度數（degrees，簡稱 deg）。
    - 與笛卡爾坐標的轉換
        - 極坐標 $(r, \theta)$ 到笛卡爾坐標 $(x, y)$ 的轉換：$(x, y) = (r \cdot \cos(\theta), r \cdot \sin(\theta))$
        - 笛卡爾坐標 $(x, y)$ 到極坐標 $(r, \theta)$ 的轉換：$(r, \theta) = (\sqrt{x^2 + y^2}, \arctan2(y, x))$
- 在標準方位角中 0 度通常指向北方，但因為我的雷達是 0 度在左，所以要逆時針轉 90 度，也就是角度要 - 90。
- 由於 JS 的 `Math.sin()`、`Math.cos()` 和 D3.js 的 `arc()` 都是用 rad 為單位，所以會需要把 deg 轉成 rad。
- Zulu 時間其實就是 UTC（世界協調時間）在軍事和航空領域的別稱，採用軍事風格的寫法去掉秒和冒號，感覺起來更緊湊一些XD

```javascript
// 上面畫雷達的部分沒什麼好講的，基本上就是在畫圖而已🫠

const updateScanResult = ({ angle, distance }) => {
  const arcGenerator = (innerRadius, outerRadius) =>
    d3
      .arc()
      .innerRadius(innerRadius)
      .outerRadius(outerRadius)
      .startAngle(toRads(angle - 0.51))
      .endAngle(toRads(angle + 0.51))

  // 繪製掃描線
  grid
    .append("path")
    .attr("d", arcGenerator(0, (distance / 200) * r))
    .attr("fill", "#3a3")
    .attr("opacity", 1)
    .transition()
    .duration(SCAN_DURATION / 2)
    .ease(d3.easeCubicIn) // 相當於 easePolyIn.exponent(3)
    .attr("opacity", 0)
    .remove()

  
  if (distance < 200) {
    grid
      .append("path")
      .attr("d", arcGenerator((distance / 200) * r, r))
      .attr("fill", "#8b0000")
      .attr("opacity", 1)
      .transition()
      .duration(SCAN_DURATION / 2)
      .ease(d3.easeCubicIn) // 相當於 easePolyIn.exponent(3)
      .attr("opacity", 0)
      .remove()
  }
```
- `arcGenerator` 每拿到一筆資料就畫一個略多於 1 度一咪咪的圓弧，這樣拼起來就會有雷達掃描的感覺。之所以不剛好等於 1 度的原因是這樣可以盡量把每一個圓弧的邊線藏起來，看起來比較像是一個完整的面。
- 因為我希望掃描完的地方慢慢消失，所以用了 D3.js 的 [ease 相關方法](https://d3js.org/d3-ease)。
- 當資料的距離小於 200 則從掃描到的地方開始改為紅色，代表有掃描到物體。

## 結語
自己動手做完一個小專案，不免俗遇到一些難關但也慢慢克服。比起軟體的純虛擬，還是硬體更有實體感吶🍵
之後應該會繼續玩其他不同的東西，比如說寫個 app（正在自學 Flutter 中...）來控制機器人或四軸飛行器之類的，到時候再來跟大家分享囉！
最後，如果對於文章有任何問題或是建議都歡迎留言跟我說，這樣才能進步得更快一點 ʕ •ᴥ•ʔ🫶🏿