2026年7月8日 星期三

【Arduino UNO Q 專欄05】- 家庭氣象站

Jack OmniXRI, 2026/07/01

Arduino-UNO-Q-05_Fig00

在物聯網時代,能隨時監看家中環境變化是很常見的應用,因此各式感測器更是不可或缺,如溫度、濕度、氣壓、照度甚至空氣品質(PM2.5)等,而這些感測器通常都會使用如 UART, I2C, SPI 等串列通訊方式將收集到的資訊傳送回單晶片開發板以利後續處理。

在以往 Arduino Uno R3/R4 時代,大家都會將感測器模組插在麵包板上,再使用杜邦線連接到主板的排插上,但常會因為線材品質不佳或接點鬆脫導致感測器信號中斷,於是 Arduino UNO Q [1](以下簡稱 UNO Q) 借鑑 SparkFun 可快速連接並能以菊花鏈(Daisy-chain)串聯 I2C 的連接方式「QWIIC」,讓大家可更穩定連接週邊,同時推出可支援 QWIIC 的 Modulino 系列週邊。

本文將藉 UNO Q App Lab [2] 範例「Home climate monitoring and storage [3]」進行修改,使其可以搭配常見氣溫和氣壓感測器 Bosch BMP280[4] 變成自定義感測器的家庭氣象站,未來大家還可依此擴展到更多的感測器上,實現更完整的智慧家庭應用。

1. I2C 、 QWIIC 與 Modulino 模組

目前市售許多感測器模組、儲存元件(EEPROM等)、OLED顯示器等都會使用 I2C(Inter-Integrated Circuit, IIC / I²C / I2C) 通訊介面來連接單晶片(MCU)或微處理器(MPU)控制板,亦可作為兩塊控制板相互通訊使用。

如 Fig. 1 右上圖所示,傳統 I2C 只需串列資料線(SDA)、時脈線(SCL)兩條訊號線及電源(3.3或5.0V視模組元件而異)即可,多個感測器模組可當成從(Slave)裝置進行並聯。另外為了讓多個並聯裝置可以清楚的讀寫訊號,通常會在 SDA 及 SCL 上串接一個提升電阻。 所有從裝置必須設置一組固定位址(通常為7bit 0x00~0x7F 或 10bit 0x000~0x3FF)且不能重覆,工作時從裝置會一直監聽 SDA ,當聽到特定從裝置位址時,就會依命令進行讀寫,很適合多組讀寫頻度不高的感測器模組交替傳輸資料使用。

從 Arduino UNO R4 WIFI 起就開始增加了 QWIIC 接頭,UNO Q 也有配置。如 Fig. 1 右下圖所示,這是借鑑 SparkFun 可快速連接並能以菊花鏈(Daisy-chain)串聯 I2C 的連接方式。基本上它就是 I2C 接頭,只是統一了接線順序、顏色及供電規格,四芯分別為GND(黑)、3.3V輸出(紅)、SDA(藍)及SCL(黃)。而每個模組中 SDA / SCL 已自帶提升電阻。如果串接太多模組時,由於提升電阻被多次並聯,電阻值會下降,有時會影響通訊信號品質,必須手動移除部份模組的提升電阻才能改善。

在 UNO Q 板子上 I2C 是使用左上角的 SCL(D21)和 SDA(D20),程式初始化部份是使用 <wire.h> 的 Wire.begin() 。而 QWIIC 則是使用獨立的接頭,程式初始化部份則是使用 Wire1.begin()。

Arduino-UNO-Q-05_Fig01
Fig. 1 Arduino UNO Q I2C 與 QWIIC 介面接線定義圖 。(OmniXRI整理製作,2026/07/01)

Arduino 為了方便大家,亦有推出可支援 QWIIC 的 Modulino 系列週邊[5],如按鍵、蜂鳴器、距離偵測、類比旋鈕、運動感測、LED點陣、溫度感測、搖桿、繼電器、馬達控制模組等,如 Fig. 2 所示。若一時買不到亦可依 I2C 接線方式自行 DIY 一組相同功能的模組,亦可依此規格推出自定規格的 QWIIC 模組。

Arduino-UNO-Q-05_Fig02
Fig. 2 Arduino QWIIC 介面模組 ─ Modulino 家族產品 。(OmniXRI整理製作,2026/07/01)

2. BMP280 氣溫氣壓模組取樣程式

為了快速建立一個家庭氣象站, Arduino UNO Q APP LAB(以下簡稱 APP LAB)提供了「Home climate monitoring and storage [3]」範例。在這個範例中使用了 QWIIC 介面的 Modulino Thermo[5],該模組主要使用了 Renesas HS3003 溫濕度感測器。

由於大家可能不易取得,在本文中改使用 I2C 介面的 Bosch BMP280[4] 氣溫和氣壓感測器來進行實驗。這樣更方便大家依此類推更換成其它 I2C 或 QWIIC 介面感測器。完整 BMP280 規格可參考 Fig. 3 所示,更進一步介紹可參考「南開科大智慧物聯網資料收集站工作坊【05-I2C】[6]」。

Arduino-UNO-Q-05_Fig03
Fig. 3 Bosch BMP280 氣溫氣壓感測器基本規格。(OmniXRI整理製作,2026/07/01)

為了讓這個範例能順利透過 I2C 讀取 BMP280 產生的氣溫和氣壓值。如 Fig. 4 所示,首先開啟 APP LAB,點選左側範例(Examples),再選擇「Home climate monitoring and storage」範例,雖然開發板並沒有接範例中所需的「Modulino Thermo」QWIIC模組,但程式還是可以執行的,點擊「執行(Run)」,稍等程式編譯完,就會自動開啟網頁顯示已讀取到的溫度、濕度及計算出的露點、體感溫度及絕對濕度等資訊,如 Fig. 4 右上圖所示。

若網頁沒有自動開啟,可自行開啟網頁瀏覽器(如Chrome, Firefox, Microsoft Edge等)輸入網址 http://localhost:7000 即可得到一樣的結果。另外亦可使用開發板動態配置的無線網路網址取代 localhost,若不清楚目前網址可點選 APP LAB 右下角地球圖案即會顯示無線網路資訊。接著在瀏覽器輸入 http://192.168.x.x:7000 亦可得到同樣資訊。

最後點選右上角複製(Copy)鍵,給定專案名稱,按下新建(Create)就能建立一個新的專案,方便後續修改。

Arduino-UNO-Q-05_Fig04
Fig. 4 建立「Home climate monitoring and storage」範例。(OmniXRI整理製作,2026/07/01)

由於這個專案並未支援 BMP280 所需 Arduino LIB,如 Fig. 5 所示,須先點擊左上角「新增函式庫(Add Sketch Lib)」,再輸入 BMP280 找到合適的驅動程式,這裡我們選擇「Adafruit BMP280」函式庫,按下「安裝(Install)」即完成。

新增 LIB 後,sketch.yaml 檔會自動在 libraries 下加入 Adafruit BMP280 Library 及配套的 Adafruit BusIO 及 Adafruit Unified Sensor 函式庫。原先範例已有的 Arduino Modulino 相關函式庫後續用不到可暫時略過不用處理,完整 sketch.yaml 如下所示。
註:這個檔案只能在CLI模式編輯,無法從視窗模式編輯。

profiles: default: fqbn: platforms: - platform: arduino:zephyr libraries: - Arduino_Modulino (0.7.0) - dependency: ArduinoGraphics (1.1.4) - dependency: Arduino_HS300x (1.0.0) - dependency: Arduino_LPS22HB (1.0.2) - dependency: Arduino_LSM6DSOX (1.1.2) - dependency: Arduino_LTR381RGB (1.0.0) - dependency: STM32duino VL53L4CD (1.0.5) - dependency: STM32duino VL53L4ED (1.0.1) - Adafruit BMP280 Library (3.0.0) - dependency: Adafruit BusIO (1.17.4) - dependency: Adafruit Unified Sensor (1.1.15) default_profile: default

接下來選擇左側 sketch 路徑下的 sketch.ino,將全部內容刪除,替換成下列程式,按下左上角「執行(Run)」即可看到下方「Serial Monitor」 每隔1秒會更新顯示出現目前氣溫及氣壓值。

由於原先程式是取得溫度及濕度值,而這裡是氣溫及氣壓值,所以相關回傳給 Python 內容也要跟著變化,完整程式內容及說明如下所示。

// sketch.ino // by Jack OmniXRI, 2026/07/01 // 提供 BMP280 溫度及氣壓值給 Python 顯示 #include <Arduino_RouterBridge.h> // 導入橋接函式 #include <Wire.h> // 導入 I2C 驅動函式 #include <Adafruit_BMP280.h> // 導入 BMP280 溫度氣壓感測器驅動函式 unsigned long previousMillis = 0; // 記錄最後一次取值時間 const long interval = 1000; // 每隔 1000 ms 取樣一次 Adafruit_BMP280 bmp; // 建立 BMP280 物件(預設使用 I2C 介面) void setup() { Bridge.begin(); // 橋接函式初始化 Serial.begin(9600); // 虛擬串列埠初始化 while ( !Serial ) delay(100); // 等待序列埠視窗開啟 Serial.println(F("BMP280 測試開始")); // 在串列埠列印開始測試字串 // 初始化感測器,大多數 BMP280 模組預設位址是 0x76 // 如果失敗,嘗試傳入不同的 I2C 位址(常見有 0x76 或 0x77) unsigned status = bmp.begin(0x76); if (!status) { // 若初始化失敗,顯示提示字串 Serial.println(F("找不到正確的 BMP280 感測器,請檢查:")); Serial.println(F("1. 接線是否正確?")); Serial.println(F("2. I2C 位址是否為 0x77?(可將 bmp.begin(0x76) 改為 0x77 試試)")); while (1) delay(10); // 卡死程式,等待硬體重啟 } // 設定感測器參數(使用函式庫預設值即可) bmp.setSampling(Adafruit_BMP280::MODE_NORMAL, // 工作模式 Adafruit_BMP280::SAMPLING_X2, // 溫度過採樣 Adafruit_BMP280::SAMPLING_X16, // 氣壓過採樣 Adafruit_BMP280::FILTER_X16, // 濾波器設定 Adafruit_BMP280::STANDBY_MS_500); // 停等時間 } void loop() { unsigned long currentMillis = millis(); // 獲取目前時間 (ms) // 若目前時間和前次時間差值大於設定值,則輸出溫度程氣壓值 if (currentMillis - previousMillis >= interval) { // 更新前次時間值為目前時間值 previousMillis = currentMillis; // 讀取目前溫度值(攝氏 ℃) float celsius = bmp.readTemperature(); Serial.println("目前溫度:" + String(celsius) + " 度(℃)"); // 讀取目前氣壓值(帕期卡 Pa) float pressure = bmp.readPressure(); Serial.println("目前氣壓:" + String(pressure) + " 帕斯卡(Pa)"); // 通知(輸出)目前溫度及氣壓值 Bridge.notify("record_sensor_samples", celsius, pressure); } }

Arduino-UNO-Q-05_Fig05
Fig. 5 新增 Arduino LIB - Adafruit BMP280 函式庫、Sketch程式及執行結果。(OmniXRI整理製作,2026/07/01)

3. Arduino 磚塊(Brick) ─ 資料記錄與網頁介面

如下 app.yaml 所示,在此範例中主要會使用到兩個磚塊:資料庫儲存、時間序列儲存(dbstorage_tsstore)及網頁使用者介面(web_ui),分別用於儲存及顯示收集到的感測器資料。

name: Copy of Home climate monitoring and storage description: A simple data logger that gets temperature and humidity from the board via Modulino Thermo and stores them in a database. ports: [] bricks: - arduino:dbstorage_tsstore: {} - arduino:web_ui: {} icon: 🎛️

同樣地,為了讓 Python 程式也能正確接收並顯示感測器獲得的數值,點選左側 python 路徑下的 main.py,刪除並替換成下列所有內容。在這個程式中,Python處於被動狀態,當 MCU 更新感測器內容時,會透過 Brideg 呼叫 record_sensor_samples() 函式記錄氣溫及氣壓值並以 send_message() 送到網頁使用者介面,此時網頁會依據一日(1D)、一小時(1h)和即時(live)選項利用 on_get_samples() 函式取回並顯示最近資料集中的內容。

# main.py # by Jack OmniXRI, 2026/07/01 # 接收、記錄 BMP280 取得之溫度及氣壓值,並顯示在網頁上 import datetime import math from arduino.app_bricks.dbstorage_tsstore import TimeSeriesStore from arduino.app_bricks.web_ui import WebUI from arduino.app_utils import App, Bridge db = TimeSeriesStore() # 初始化時間序列儲存器 # 當取得感測器回傳值 def on_get_samples(resource: str, start: str, aggr_window: str): samples = db.read_samples(measure=resource, start_from=start, aggr_window=aggr_window, aggr_func="mean", limit=100) return [{"ts": s[1], "value": s[2]} for s in samples] ui = WebUI() # 初始化網頁介面 ui.expose_api("GET", "/get_samples/{resource}/{start}/{aggr_window}", on_get_samples) # 記錄感測器取樣值 def record_sensor_samples(celsius: float, pressure: float): if celsius is None or pressure is None: print("Received invalid sensor samples: celsius=%s, pressurd=%s" % (celsius, pressure)) return ts = int(datetime.datetime.now().timestamp() * 1000) # 取得時間戳 # 將溫度和氣壓值寫入時間序列資料庫中 db.write_sample("temperature", float(celsius), ts) db.write_sample("pressure", float(pressure), ts) # 將溫度和氣壓值顯示在網頁上 ui.send_message('temperature', {"value": float(celsius), "ts": ts}) ui.send_message('pressure', {"value": float(pressure), "ts": ts}) print("Registering 'record_sensor_samples' callback.") # 宣告記錄感測器回呼函式 Bridge.provide("record_sensor_samples", record_sensor_samples) print("Starting App...") App.run()

原範例程式網頁使用者介面顯示內容共有五項(如Fig. 4右上圖示),而這裡只需氣溫和氣壓,如 Fig. 6 所示,所以必須修正左側 assets 路徑下的 index.html 及 app.js。同樣地只需開啟後,將下列 index.html 及 app.js 內容全部刪除後再貼上下列內容即完成。主要修改是將濕度(Humidity, %)變成氣壓(Pressure, Pa),並刪除露點(Dew point)、體感溫度(Heat Index)及絕對濕度(Absolute Humidity)等相關內容。

<!-- index.html SPDX-FileCopyrightText: Copyright (C) Arduino s.r.l. and/or its affiliated companies SPDX-License-Identifier: MPL-2.0 --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Home climate monitoring and storage</title> <link rel="stylesheet" type="text/css" href="style.css"> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="libs/socket.io.min.js"></script> <script src="app.js"></script> </head> <body> <div class="app-container"> <div class="header"> <h1 class="arduino-text">Home climate monitoring and storage</h1> <img class="arduino-logo" src="./img/RGB-Arduino-Logo_Color Inline Loop.svg" alt="Arduino Logo"> </div> <div class="tabs"> <button class="tab" data-tab="historical-1d">1 D</button> <button class="tab" data-tab="historical-1h">1 h</button> <button class="tab-with-circle tab active" data-tab="live"> <span id="live-circle"></span> Live </button> </div> <div class="main-content"> <div id="historical-1d" class="tab-content"> <div class="top-row"> <div class="container"> <div class="graph-header"> <span>Temperature (°C)</span> <img src="./img/info.svg" alt="Info" class="info-btn temp"/> <div class="popover"></div> </div> <canvas id="temperature-1d-chart"></canvas> <div id="temperature-1d-chart-nodata" class="nodata-container"> <img src="./img/nodata.svg" class="nodata-img"> <span class="no-data">No data</span> </div> </div> <div class="container"> <div class="graph-header"> <span>Pressure (Pa)</span> <img src="./img/info.svg" alt="Info" class="info-btn pressure"/> <div class="popover"></div> </div> <canvas id="pressure-1d-chart"></canvas> <div id="pressure-1d-chart-nodata" class="nodata-container"> <img src="./img/nodata.svg" class="nodata-img"> <span class="no-data">No data</span> </div> </div> </div> </div> <div id="historical-1h" class="tab-content"> <div class="top-row"> <div class="container"> <div class="graph-header"> <span>Temperature (°C)</span> <img src="./img/info.svg" alt="Info" class="info-btn temp"/> <div class="popover"></div> </div> <canvas id="temperature-1h-chart"></canvas> <div id="temperature-1h-chart-nodata" class="nodata-container"> <img src="./img/nodata.svg" class="nodata-img"> <span class="no-data">No data</span> </div> </div> <div class="container"> <div class="graph-header"> <span>Pressure (Pa)</span> <img src="./img/info.svg" alt="Info" class="info-btn pressure"/> <div class="popover"></div> </div> <canvas id="pressure-1h-chart"></canvas> <div id="pressure-1h-chart-nodata" class="nodata-container"> <img src="./img/nodata.svg" class="nodata-img"> <span class="no-data">No data</span> </div> </div> </div> </div> <div id="live" class="tab-content active"> <div class="top-row"> <div class="container"> <div class="graph-header"> <span>Temperature (°C)</span> <img src="./img/info.svg" alt="Info" class="info-btn temp"/> <div class="popover"></div> </div> <canvas id="temperature-live-chart"></canvas> <div id="temperature-live-chart-nodata" class="nodata-container"> <img src="./img/nodata.svg" class="nodata-img"> <span class="no-data">No data</span> </div> </div> <div class="container"> <div class="graph-header"> <span>Pressure (Pa)</span> <img src="./img/info.svg" alt="Info" class="info-btn pressure"/> <div class="popover"></div> </div> <canvas id="pressure-live-chart"></canvas> <div id="pressure-live-chart-nodata" class="nodata-container"> <img src="./img/nodata.svg" class="nodata-img"> <span class="no-data">No data</span> </div> </div> </div> </div> </div> <div id="error-container" class="error-message" style="display: none;"></div> </div> </body> </html>
// app.js
// SPDX-FileCopyrightText: Copyright (C) Arduino s.r.l. and/or its affiliated companies
//
// SPDX-License-Identifier: MPL-2.0

const socket = io(`http://${window.location.host}`);

// Temperature and Pressure chart objects
const temperatureLive = { canvas: null, chart: null, data: newChartData('orange', 'rgba(255,165,0,0.1)'), unit: '°C' };
const pressureLive = { canvas: null, chart: null, data: newChartData('teal', 'rgba(0,128,128,0.08)'), unit: 'Pa' };

const temperature1h = { canvas: null, chart: null, data: newChartData('orange', 'rgba(255,165,0,0.1)'), unit: '°C' };
const pressure1h = { canvas: null, chart: null, data: newChartData('teal', 'rgba(0,128,128,0.08)'), unit: 'Pa' };

const temperature1d = { canvas: null, chart: null, data: newChartData('orange', 'rgba(255,165,0,0.1)'), unit: '°C' };
const pressure1d = { canvas: null, chart: null, data: newChartData('teal', 'rgba(0,128,128,0.08)'), unit: 'Pa' };

let liveCircleTimeout = null;
const noDataTimeout = 10000; // 10 seconds

let errorContainer;


document.addEventListener('DOMContentLoaded', () => {
    // Initialize canvases
    temperatureLive.canvas = document.getElementById('temperature-live-chart');
    pressureLive.canvas = document.getElementById('pressure-live-chart');
    temperature1h.canvas = document.getElementById('temperature-1h-chart');
    pressure1h.canvas = document.getElementById('pressure-1h-chart');
    temperature1d.canvas = document.getElementById('temperature-1d-chart');
    pressure1d.canvas = document.getElementById('pressure-1d-chart');

    // The live circle is hidden initially until data come
    const liveCircle = document.getElementById('live-circle');
    if (liveCircle) liveCircle.style.display = 'none';

    errorContainer = document.getElementById('error-container');

    // Tab switching logic
    document.querySelectorAll('.tab').forEach(tab => {
        tab.addEventListener('click', function() {
            document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
            document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
            this.classList.add('active');
            document.getElementById(this.dataset.tab).classList.add('active');
        });
    });

    document.querySelector('.tab[data-tab="historical-1h"]').addEventListener('click', async () => {
        const temperature_samples = await listSamples("temperature", "-1h", "5m");
        renderChartData(temperature1h, temperature_samples, 12, true, false);
        const pressure_samples = await listSamples("pressure", "-1h", "5m");
        renderChartData(pressure1h, pressure_samples, 12, true, false);
    });
    document.querySelector('.tab[data-tab="historical-1d"]').addEventListener('click', async () => {
        const temperature_samples = await listSamples("temperature", "-1d", "1h");
        renderChartData(temperature1d, temperature_samples, 24, false, false);
        const pressure_samples = await listSamples("pressure", "-1d", "1h");
        renderChartData(pressure1d, pressure_samples, 24, false, false);
    });

    // Popover logic for Temperature and Pressure info buttons
    const tempPopoverText = "Shows temperature readings in °C. Data is average per 1h (1D view) or per 1 minute (1h view)";
    const pressurePopoverText = "Shows pressure readings in Pa. Data is average per 1h (1D view) or per 1 minute (1h view)";
    document.querySelectorAll('.info-btn.temp').forEach(img => {
        img.style.position = 'relative';
        const popover = img.nextElementSibling;
        img.addEventListener('mouseenter', () => {
            popover.textContent = tempPopoverText;
            popover.style.display = 'block';
        });
        img.addEventListener('mouseleave', () => {
            popover.style.display = 'none';
        });
    });
    document.querySelectorAll('.info-btn.pressure').forEach(img => {
        img.style.position = 'relative';
        const popover = img.nextElementSibling;
        img.addEventListener('mouseenter', () => {
            popover.textContent = pressurePopoverText;
            popover.style.display = 'block';
        });
        img.addEventListener('mouseleave', () => {
            popover.style.display = 'none';
        });
    });
    initSocketIO();
});

function initSocketIO() {
    socket.on('connect', () => {
        if (errorContainer) {
            errorContainer.style.display = 'none';
            errorContainer.textContent = '';
        }
    });

    socket.on('disconnect', (reason) => {
        if (errorContainer) {
            errorContainer.textContent = 'Connection to the board lost. Please check the connection.';
            errorContainer.style.display = 'block';
        }
    });

    // Temperature and Pressure live updates
    socket.on('temperature', (message) => {
        renderChartData(temperatureLive, [message]);
    });

    socket.on('pressure', (message) => {
        renderChartData(pressureLive, [message]);
    });
}

async function listSamples(resource, start, aggr_window) {
    try {
        const response = await fetch(`http://${window.location.host}/get_samples/${resource}/${start}/${aggr_window}`);
        if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
        const data = await response.json();
        if (data.error) {
            console.log(`Failed to get samples: ${data.error}`);
            return;
        }
        return data;
    } catch (error) {
        console.log(`Error fetching samples: ${error.message}`);
    }
}

function renderChartData(obj, messages, maxPoints = 20, showMinutes = true, showSeconds = true) {
    if (!messages || messages.length === 0) {
        return;
    }

    const noDataDiv = document.getElementById((obj.canvas && obj.canvas.id) + '-nodata');
    const liveCircle = document.getElementById('live-circle');
    const isLiveChart = obj.canvas && obj.canvas.id && obj.canvas.id.endsWith('-live-chart');

    // Only clear data for non-live charts
    if (!isLiveChart) {
        obj.data.labels = [];
        obj.data.datasets[0].data = [];
    }

    for (const message of messages) {
        if (!message.ts) {
            console.warn('Invalid message format:', message);
            continue;
        }

        let date = new Date(message.ts);
        if (showMinutes && showSeconds) {
            date = date.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', second: '2-digit'});
        } else if (showMinutes) {
            date = date.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
        } else {
            date = date.toLocaleTimeString([], {hour: '2-digit'});
        }

        obj.data.labels.push(date);
        obj.data.datasets[0].data.push(message.value);

        // Keep only the n points
        if (obj.data.labels.length > maxPoints) {
            obj.data.labels.shift();
            obj.data.datasets[0].data.shift();
        }

        if (obj.data.labels.length === 0 || obj.data.datasets[0].data.length === 0) {
            if (obj.canvas) obj.canvas.style.display = 'none';
            if (noDataDiv) noDataDiv.style.display = 'flex';
            if (isLiveChart && liveCircle) {
                liveCircle.style.display = 'none';
                liveCircle.classList.remove('flash');
                if (liveCircleTimeout) {
                    clearTimeout(liveCircleTimeout);
                    liveCircleTimeout = null;
                }
            }
            if (obj.chart) {
                obj.chart.destroy();
                obj.chart = null;
            }
        } else {
            if (obj.canvas) obj.canvas.style.display = 'block';
            if (noDataDiv) noDataDiv.style.display = 'none';
            if (isLiveChart && liveCircle) {
                liveCircle.style.display = 'flex';
                liveCircle.classList.add('flash');
                if (liveCircleTimeout) clearTimeout(liveCircleTimeout);
                liveCircleTimeout = setTimeout(() => {
                    liveCircle.classList.remove('flash');
                    liveCircle.style.display = 'none';
                }, noDataTimeout);
            }
            if (!obj.chart) {
                obj.chart = newChart(obj.canvas.getContext('2d'), obj);
            } else {
                obj.chart.update();
            }
        }
    }
}

function newChart(ctx, obj) {
    return new Chart(ctx, {
        type: 'line',
        data: obj.data,
        options: {
            responsive: true,
            animation: false,
            scales: {
                y: obj.unit === '%' ? { min: 0, max: 100 } : {},
                x: {
                    grid: { display: false },
                    ticks: {
                        maxRotation: 45,
                        minRotation: 45
                    }
                }
            },
            interaction: {
                mode: 'index',
                intersect: false
            },
            plugins: {
                legend: { display: false },
                tooltip: {
                    displayColors: false,
                    callbacks: {
                        title: function() { return ''; },
                        label: function(context) {
                            const unit = context.chart && context.chart.options && context.chart.options._unit ? context.chart.options._unit : (obj.unit || '');
                            // store unit in chart options for future reference
                            if (!context.chart.options._unit) context.chart.options._unit = obj.unit;
                            return `${context.label} - ${context.parsed.y.toFixed(1)} ${unit}`;
                        }
                    }
                },
                noDataMessage: true
            }
        }
    });
}

function newChartData(borderColor, backgroundColor) {
    return {
        labels: [],
        datasets: [{
            data: [],
            borderColor: borderColor,
            backgroundColor: backgroundColor,
            fill: true,
        }]
    };
}

最後修正完的網頁使用者介面如 Fig. 6 所示。

Arduino-UNO-Q-05_Fig06
Fig. 6 修正後即時感測器數值顯示結果。(OmniXRI整理製作,2026/07/01)

結語

透過本文及相關範例程式可讓大家了解到 Arduino UNO Q 新式 I2C 介面「QWIIC」及如何連接、讀取各式 I2C 感測器內容,同時可依需求修改顯示的網頁使用者人機介面。後續依此類推就能納入更多感測器,讓自己動手完成一個智慧家庭氣象站不再是難事。

本文同步發表在 MakerPRO

參考文獻

[1] Arduino, Document - Hardware - UNO Q - UNO Q User Manual
https://docs.arduino.cc/tutorials/uno-q/user-manual/
[2] Arduino, Document - Software - APP Lab - Tutorial: Using Arduino App Lab
https://docs.arduino.cc/software/app-lab/getting-started/quickstart/
[3] Github, Arduino App Brick Examples - Home Climate Monitoring
https://github.com/arduino/app-bricks-examples/tree/main/examples/common/home-climate-monitoring-and-storage
[4] Bosch, Digital Pressure Sensor BMP280
https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bmp280-ds001.pdf
[5] Arduino, Hardware - Modulino
https://www.arduino.cc/en/hardware/#modulino
[6] 許哲豪,南開科大智慧物聯網資料收集站工作坊【05-I2C】
https://hackmd.io/@OmniXRI-Jack/NKUT_Data_Logger_Course_05

延伸閱讀

[A] 許哲豪,歐尼克斯實境互動工作室【系列發文】 - Arduino UNO Q 專欄
https://hackmd.io/@OmniXRI-Jack/series_articles#Arduino-UNO-Q-專欄

沒有留言:

張貼留言

【頂置】簡報、源碼、系列文快速連結區

常有人反應用手機瀏覽本部落格時常要捲很多頁才能找到系列發文、開源專案、課程及活動簡報,為了方便大家快速查詢,特整理連結如下,敬請參考! Edge AI Taiwan 邊緣智能交流區 全像顯示與互動交流區 台科大(NTUST)邊緣人工智慧實務(2026) 南開科大...