Jack OmniXRI, 2026/07/01

在物聯網時代,能隨時監看家中環境變化是很常見的應用,因此各式感測器更是不可或缺,如溫度、濕度、氣壓、照度甚至空氣品質(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()。
Fig. 1 Arduino UNO Q I2C 與 QWIIC 介面接線定義圖 。(OmniXRI整理製作,2026/07/01)Arduino 為了方便大家,亦有推出可支援 QWIIC 的 Modulino 系列週邊[5],如按鍵、蜂鳴器、距離偵測、類比旋鈕、運動感測、LED點陣、溫度感測、搖桿、繼電器、馬達控制模組等,如 Fig. 2 所示。若一時買不到亦可依 I2C 接線方式自行 DIY 一組相同功能的模組,亦可依此規格推出自定規格的 QWIIC 模組。

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]」。

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)就能建立一個新的專案,方便後續修改。

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模式編輯,無法從視窗模式編輯。
接下來選擇左側 sketch 路徑下的 sketch.ino,將全部內容刪除,替換成下列程式,按下左上角「執行(Run)」即可看到下方「Serial Monitor」 每隔1秒會更新顯示出現目前氣溫及氣壓值。
由於原先程式是取得溫度及濕度值,而這裡是氣溫及氣壓值,所以相關回傳給 Python 內容也要跟著變化,完整程式內容及說明如下所示。
Fig. 5 新增 Arduino LIB - Adafruit BMP280 函式庫、Sketch程式及執行結果。(OmniXRI整理製作,2026/07/01)3. Arduino 磚塊(Brick) ─ 資料記錄與網頁介面
如下 app.yaml 所示,在此範例中主要會使用到兩個磚塊:資料庫儲存、時間序列儲存(dbstorage_tsstore)及網頁使用者介面(web_ui),分別用於儲存及顯示收集到的感測器資料。
同樣地,為了讓 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)等相關內容。
const socket = io(`http://${window.location.host}`);
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;
let errorContainer;
document.addEventListener('DOMContentLoaded', () => {
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');
const liveCircle = document.getElementById('live-circle');
if (liveCircle) liveCircle.style.display = 'none';
errorContainer = document.getElementById('error-container');
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);
});
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';
}
});
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');
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);
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 || '');
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 所示。
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-專欄
沒有留言:
張貼留言