# 用 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 中...)來控制機器人或四軸飛行器之類的,到時候再來跟大家分享囉! 最後,如果對於文章有任何問題或是建議都歡迎留言跟我說,這樣才能進步得更快一點 ʕ •ᴥ•ʔ🫶🏿