|

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

   

作者:Jack OmniXRI

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

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

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

1. I2C 、 QWIIC 與 Modulino 模組

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

如圖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也有配置。如圖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()。

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

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

圖2:Arduino QWIIC介面模組 ─ Modulino 家族產品 。(OmniXRI整理製作,2026/07/01)

圖2:Arduino QWIIC介面模組 ─ Modulino 家族產品 。(OmniXRI整理製作,2026/07/01)

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

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

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

Bosch BMP280 氣溫氣壓感測器基本規格。(OmniXRI整理製作,2026/07/01)

圖3:Bosch BMP280 氣溫氣壓感測器基本規格。(OmniXRI整理製作,2026/07/01)

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

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

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

建立「Home climate monitoring and storage」範例。(OmniXRI整理製作,2026/07/01)

圖4:建立「Home climate monitoring and storage」範例。(OmniXRI整理製作,2026/07/01)

由於這個專案並未支援BMP280所需Arduino LIB,如圖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  // 導入橋接函式
#include  // 導入 I2C 驅動函式
#include  // 導入 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 &gt;= 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);    
  }
}
圖5:新增 Arduino LIB - Adafruit BMP280 函式庫、Sketch程式及執行結果。(OmniXRI整理製作,2026/07/01)

圖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()

原範例程式網頁使用者介面顯示內容共有五項(如圖4右上所示),而這裡只需氣溫和氣壓,如圖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 -->




   
   
   
   
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script><script src="libs/socket.io.min.js"></script><script src="app.js"></script></pre>
<div class="app-container">

&nbsp;
<div class="header">

&nbsp;
<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>
&nbsp;
<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>
&nbsp;
<div class="main-content">

&nbsp;
<div id="historical-1d" class="tab-content">

&nbsp;
<div class="top-row">

&nbsp;
<div class="container">

&nbsp;
<div class="graph-header">

Temperature (°C)
<img class="info-btn temp" src="./img/info.svg" alt="Info" />
<div class="popover"></div>
&nbsp;

</div>
&nbsp;
<div id="temperature-1d-chart-nodata" class="nodata-container"><img class="nodata-img" src="./img/nodata.svg" />
<span class="no-data">No data</span></div>
&nbsp;

</div>
&nbsp;
<div class="container">

&nbsp;
<div class="graph-header">

Pressure (Pa)
<img class="info-btn pressure" src="./img/info.svg" alt="Info" />
<div class="popover"></div>
&nbsp;

</div>
&nbsp;
<div id="pressure-1d-chart-nodata" class="nodata-container"><img class="nodata-img" src="./img/nodata.svg" />
<span class="no-data">No data</span></div>
&nbsp;

</div>
&nbsp;

</div>
&nbsp;

</div>
&nbsp;
<div id="historical-1h" class="tab-content">

&nbsp;
<div class="top-row">

&nbsp;
<div class="container">

&nbsp;
<div class="graph-header">

Temperature (°C)
<img class="info-btn temp" src="./img/info.svg" alt="Info" />
<div class="popover"></div>
&nbsp;

</div>
&nbsp;
<div id="temperature-1h-chart-nodata" class="nodata-container"><img class="nodata-img" src="./img/nodata.svg" />
<span class="no-data">No data</span></div>
&nbsp;

</div>
&nbsp;
<div class="container">

&nbsp;
<div class="graph-header">

Pressure (Pa)
<img class="info-btn pressure" src="./img/info.svg" alt="Info" />
<div class="popover"></div>
&nbsp;

</div>
&nbsp;
<div id="pressure-1h-chart-nodata" class="nodata-container"><img class="nodata-img" src="./img/nodata.svg" />
<span class="no-data">No data</span></div>
&nbsp;

</div>
&nbsp;

</div>
&nbsp;

</div>
&nbsp;
<div id="live" class="tab-content active">

&nbsp;
<div class="top-row">

&nbsp;
<div class="container">

&nbsp;
<div class="graph-header">

Temperature (°C)
<img class="info-btn temp" src="./img/info.svg" alt="Info" />
<div class="popover"></div>
&nbsp;

</div>
&nbsp;
<div id="temperature-live-chart-nodata" class="nodata-container"><img class="nodata-img" src="./img/nodata.svg" />
<span class="no-data">No data</span></div>
&nbsp;

</div>
&nbsp;
<div class="container">

&nbsp;
<div class="graph-header">

Pressure (Pa)
<img class="info-btn pressure" src="./img/info.svg" alt="Info" />
<div class="popover"></div>
&nbsp;

</div>
&nbsp;
<div id="pressure-live-chart-nodata" class="nodata-container"><img class="nodata-img" src="./img/nodata.svg" />
<span class="no-data">No data</span></div>
&nbsp;

</div>
&nbsp;

</div>
&nbsp;

</div>
&nbsp;

</div>
&nbsp;
<div id="error-container" class="error-message" style="display: none;"></div>
&nbsp;

</div>
&nbsp;
<pre>

 


// 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', () =&gt; {
    // 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 =&gt; {
        tab.addEventListener('click', function() {
            document.querySelectorAll('.tab').forEach(t =&gt; t.classList.remove('active'));
            document.querySelectorAll('.tab-content').forEach(tc =&gt; 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 () =&gt; {
        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 () =&gt; {
        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 =&gt; {
        img.style.position = 'relative';
        const popover = img.nextElementSibling;
        img.addEventListener('mouseenter', () =&gt; {
            popover.textContent = tempPopoverText;
            popover.style.display = 'block';
        });
        img.addEventListener('mouseleave', () =&gt; {
            popover.style.display = 'none';
        });
    });
    document.querySelectorAll('.info-btn.pressure').forEach(img =&gt; {
        img.style.position = 'relative';
        const popover = img.nextElementSibling;
        img.addEventListener('mouseenter', () =&gt; {
            popover.textContent = pressurePopoverText;
            popover.style.display = 'block';
        });
        img.addEventListener('mouseleave', () =&gt; {
            popover.style.display = 'none';
        });
    });
    initSocketIO();
});

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

    socket.on('disconnect', (reason) =&gt; {
        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) =&gt; {
        renderChartData(temperatureLive, [message]);
    });

    socket.on('pressure', (message) =&gt; {
        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 &amp;&amp; obj.canvas.id) + '-nodata');
    const liveCircle = document.getElementById('live-circle');
    const isLiveChart = obj.canvas &amp;&amp; obj.canvas.id &amp;&amp; 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 &amp;&amp; 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 &gt; 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 &amp;&amp; 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 &amp;&amp; liveCircle) {
                liveCircle.style.display = 'flex';
                liveCircle.classList.add('flash');
                if (liveCircleTimeout) clearTimeout(liveCircleTimeout);
                liveCircleTimeout = setTimeout(() =&gt; {
                    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 &amp;&amp; context.chart.options &amp;&amp; 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,
        }]
    };
}

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

修正後即時感測器數值顯示結果。(OmniXRI整理製作,2026/07/01)

圖6:修正後即時感測器數值顯示結果。(OmniXRI整理製作,2026/07/01)

結語

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

(責編:Judith Cheng;編按:本文內的程式碼可能因顯示模式而有跑版情況,完整內容也可參考作者的部落格)

許 哲豪

訂閱MakerPRO知識充電報

與40000位開發者一同掌握科技創新的技術資訊!

Author: 許 哲豪

工作經驗超過二十年,主要專長機電整合、電腦視覺、人機互動、人工智慧、專利分析及新創輔導。曾任機電整合工程師、機器視覺研發副理、技轉中心商業發展經理。目前擔任多家公司兼任技術顧問並積極推廣實境互動相關技術。 主持歐尼克斯實境互動工作室(OmniXRI):http://omnixri.blogspot.com Edge AI Taiwan邊緣智能交流區:https://www.facebook.com/groups/edgeaitw/

Share This Post On

Submit a Comment

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *