はじめに
前々回,前回と気圧計に関する記事を投稿して参りましたが,今回も同様の記事となります。ちょっとこれまでの主題と外れますが,「気圧計を作る」シリーズに入れてしまうことにします😅
本ブログに書いたことはありませんが,温湿度気圧センサBME280を用いた空気モニタからGoogle スプレッドシートに記録する装置(図1)を作り,ときどき稼働しておりました。秋月電子通商のキャラクタOLEDディスプレイを使って美しい文字を表示していましたが,残念なことに使用していたEPS32モジュール(互換基板)のmicroUSB端子の半田付けが劣化してしまい,動作が極めて不安定になってしまっておりました。修理または作り直しが必要だと思いながらも,しばらく放置しておりました。

そんな中,2020年頃から,Seeed Studio社による「XIAO」シリーズと呼ばれる各種の超小型マイコンボードが売り出されるようになりました(XIAOは中国語の「小 (xiǎo)」から来ているのでしょうね)。恐らく最初に発売されたSAMD21G18を搭載するもの(無印の「Seeed XIAO」または「Seeeduino XIAO」がコレでしょうか)をはじめとして,RP2040を搭載するもの,ESP32C3を搭載するものなど,ほぼピン互換,かつほぼ同じサイズで展開されており,秋葉原の電子部品ショップでも簡単かつ手頃*1に入手可能となっています。図2は本記事で用いたものと同じSeeed Studio XIAO ESP32C3です。10円硬貨と比べるとその小ささ(まさに「xiǎo」!)がお分かり頂けると思います。

温湿度気圧をセンシングして表示し,Googleスプレッドシートに記録するだけであればピン数はそれほど必要ない…という訳で,超小型ながらWiFiにも接続可能なSeeed Studio XIAO ESP32C3(以下,単にXIAOとも呼びます)と,前回も使用したキャラクタLCDを用いた空気モニタを新たに作ることにしました。
完成イメージ
図3に完成したSeeed Studio XIAO ESP32C3を用いた空気モニタを示します。

- 温湿度気圧モード
- 時計モード
- WiFiモード
- 総合表示モード
の4つを切り替えることができます。図4にそれぞれのモードの表示例を示します。

ESP32C3はWiFiに接続できますので,測定した温度,湿度,気圧を1分毎にGoogle スプレッドシートに記録します。

こんなに小さなマイコンがWiFiを介してインターネットに繋がることは,一昔前では考えられませんでしたね。まさにIoT工作に最適なデバイスと言えるでしょう。
目次
ハードウェア構成
ケース実装状態
図3からも分かるかもしれませんが,基板は2枚構成となっています。図6に製作した2枚の基板を示します。1枚目(メイン基板)はXIAOとBME280を搭載したメイン基板で,秋月電子通商の45基板に実装しています。また,2枚目の基板(サブ基板)と接続するためのピンヘッダ(6ピン)を備えております。なお,XIAOから伸びているWiFi用アンテナがプラプラしているとケース実装が難しくなるので,後述のWiFi固定パーツを作りました。

図7に示すように,45基板(一辺が45 mmの正方形であるためそう呼ばれる)はダイソーのディスプレイキューブLおよび同Mの底面にぴったりな大きさとなっています。アクリルのケースとしてもしっかりした作りになっており,これは電子工作に役立つと思い,何個かストックしてあります。

十字配線ユニバーサル基板
図8に十字配線ユニバーサル基板のカットパターンを示します。筆者は最近,電子工作をする際に回路図を描かず,最初からこのカットパターンの図で設計をするようになりました*2。
なお,当初,メイン基板において,XIAOのD8, D9, D10ピンはGNDに直接接続し,ソフトウェアで入力に設定しておけば,広いGNDパターンとして使えると思っていたのですが,プログラムが書き込めない,シリアル通信ができないという問題が生じたため,図8の赤色で示したように修正しました。ピンヘッダに向かうGNDが0 Ω抵抗器を介すことになってしまったのは褒められた実装ではありませんね…💧

サブ基板では,キャラクタLCDを基板からはみ出すように上方に実装することとし(そうしないとネジ穴に干渉してしまうため),タクトスイッチ2つを並べました。当初,普通の垂直型のピンヘッダを立てていたのですが,ケースに実装しようとしてみると,何とXIAOそのものに思いっきり干渉してしまうことが発覚したため,泣く泣く取り外して水平タイプを上向きに取り付けました。このとき,ユニバーサル基板のランドを破損したため,余っていた部品の足で電気的接続を取り直しました…。う~ん…残念…。3Dでの実装設計が必要ですね…。
WiFiアンテナ固定パーツ
前述のように,XIAOに付属のWiFiアンテナは数cmの細い同軸ケーブルの先に繋がっているシート状のものとなっており,XIAO上のIPEXコネクタに接続されます。このアンテナがプラプラしており,ケース実装の際に底面とキューブの間に挟まったりと破損の危険があるため,アンテナ固定パーツを作りました。
まず,図9に示すようにBlenderでアンテナ固定パーツを設計します。メイン基板を固定する四隅の3 mmネジの1つをつかって,シート状のアンテナを立てることを考えました。なお,アンテナには両面テープが付いているため,このパーツに貼り付けることができます。



メイン基板とサブ基板を接続してキューブに収める
メイン基板とサブ基板にはそれぞれ6ピンのピンヘッダを設けています。これをどこのご家庭にも1本はあるピンソケット付きケーブルで接続します。


実装状態は図12,13のようになりました。ピンソケット付きケーブルが長すぎたので束ねております。高速な信号ならば問題になったかもしれませんが,数100 kHz級であるI2Cでは問題なかったようです。
また,当たり前ですがXIAOに接続するためのUSBケーブルが通る孔と,BME280に外気をあてるための孔をあけました。
ソフトウェア構成
GitHubリポジトリ
GitHubにリポジトリを設けましたので,コードの詳細についてはそちらをご覧ください。 github.com
全体構成
関数単位で見ると,下記のようになっております。
- スタートアップ部: ライブラリのインクルードとオブジェクトやグローバル変数の定義
- setup関数: 各種初期設定,SW1, 2が押された際の割り込みの設定,FeeeRTOSのタスクの作成
- loop関数: 現在時刻の読み出し,気温・湿度・気圧の読み出し,LCDへの情報表示
- connectWiFi関数: WiFiへの接続(配列として与えられたSSIDへの接続をトライ)
- postGSS関数: Google スプレッドシートへのデータの送信
- onSW1関数とonSW2関数: SW1, 2が押された場合の割り込みハンドラ
それぞれを簡単に説明していきます。
スタートアップ部
スタートアップ部では,下記のようにライブラリをインクルードしています。
#include <Wire.h> #include <Adafruit_BME280.h> #include "ST7032.hpp" #include <WiFi.h> #include <WiFiClientSecure.h> #include <HTTPClient.h> #include <ArduinoJson.h>
温湿度気圧センサBME280の制御にはAdafruitのライブラリを利用させて頂きました(PICでは自分で作りましたが,やはりArduinoの便利さはここにありますね)。また,WiFiに接続するためwifi.hはもちろんのこと,Google スプレッドシートに記録するためにWiFiClientSecure.h,HTTPClient.h,ArduinoJson.hをインクルードしています。また,キャラクタLCDを制御するため,自作ライブラリST7032.hppをインクルードしています。
次に,BME280とキャラクタLCDのインスタンスを下記のように生成しています。
Adafruit_BME280 bme; ST7032 lcd;
さらに,グローバル変数を多数定義しています。プログラミングのスキルがもっと高ければ,グローバル変数はこんなに必要ないのかもしれません。もしくはWiFiへの接続状態やGoogle スプレッドシートへのPOSTの状態などそれ自体をクラス・オブジェクトとして定義すれば,グローバル変数の数は減らせるのかもしれませんね。いずれにしてもソフトウェアについて筆者はドシロートなのでこのような形になってしまいました。
// Pins and I2C device settings const int GPIO_SW1 = 20; const int GPIO_SW2 = 5; const int BME280_ADDR = 0x76; const int CONTRAST = 44; // WiFi settings const int N_SSID = 3; const char* SSID[N_SSID] = {"Your SSID 1", "Your SSID 2", "Your SSID 3"}; const char* PASSWORD[N_SSID] = {"Your password 1", "Your password 2", "Your password 3"}; const int T_DELAY = 1500; const uint32_t T_WIFI_TIMEOUT = 15000; bool online; // Constants and variables for NTP servers, date-time structure, and string const char *server1 = "ntp.nict.jp"; const char *server2 = "time.google.com"; const char *server3 = "ntp.jst.mfeed.ad.jp"; const long JST = 3600L * 9; const int summertime = 0; struct tm tm; int tm_min_old = 0; const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; const char *dayofweek[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; const char *dayofweek_short[] = {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"}; char datebuf[32]; int tm_sec_old = 0; unsigned long t_millis; // Global variables to store latest measurement results float t, h, p; // Global variables to store tactile switch conditions volatile bool sw1Pushed = false; volatile bool sw2Pushed = false; int scrMode = 0; // Constants and variables for Google Spreadsheet API constants and variables TaskHandle_t taskHandle_GSS; const char* apiURL = "Your Google Spreadsheet API URL"; int httpCode; HTTPClient http; bool postingNow = false;
setup関数
setup関数では各種の初期化や設定を行います。本システムで重要なポイントは下記です。
まず,connectWiFi関数を呼んでWiFiに接続します。WiFiに関わる様々な情報をグローバル定数に入れてしまったため,connectWiFi関数に引数はありません。さらに,configTime関数を呼んでNTPサーバから時刻を取得します。これで,本システムはNTP時計としても機能します(Google スプレッドシートに記録する際の時刻を得るため)。また,attachInterrupt関数にてタクトスイッチSW1, 2がそれぞれ押された場合の割り込みハンドラを定義します(onSW1, onSW2関数については後述)。
続くxTaskCreateUniversal関数では,Google スプレッドシートに記録するためのタスクを作成します。もちろんタスクとしてpostGSS関数を割り当てます。ESP32-DevKit Cなどに搭載されているESP32-WROOM-32Eとは異なり,ESP32C3は1コアしか搭載されておりません。そこで,タスクを実行するPRO_CPU_NUM (= 0)を指定します。コアが1つしかないので,Google スプレッドシートへの記録と温湿度・気圧の読み取りが干渉してしまうかと心配しましたが,問題なかったようです。
void setup() { (中略) // Connect to WiFi connectWiFi(); // Synchronize real-time cllock to NTP servers if (online) { configTime(JST, summertime, server1, server2, server3); } // Attach ISR for push switches attachInterrupt(GPIO_SW1, onSW1, FALLING); attachInterrupt(GPIO_SW2, onSW2, FALLING); // Create task to post to Google Spreadsheet if (online) { xTaskCreateUniversal( postGSS, // Function to be run as a task "postGSS", // Task name 8192, // Stack memory NULL, // Parameter 1, // Priority &taskHandle_GSS, // Task handler PRO_CPU_NUM // Core to run the task ); }
loop関数
ご存じのようにloop関数はsetup関数を抜けた後に繰り返し呼ばれます。この中での主な処理として,現在時刻の取得,SW1, 2の状態変化に対応する処理の記述,温湿度・気圧の読み取り,キャラクタLCDへの表示,1分毎のGoogle スプレッドシートへの記録(タスクに通知を送信)を順に行います。
現在時刻の取得
WiFiに接続されていれば,setup関数内でconfigTime関数にてNTPサーバと同期しているはずです。loop関数内ではgetLocalTime関数にて現在時刻を取得することができます。
// Get current time, if online if (online) { if (getLocalTime(&tm)) { // Store millis when second changes if (tm.tm_sec != tm_sec_old) { t_millis = millis(); } tm_sec_old = tm.tm_sec; // Create string to post to Google Spreadsheet sprintf(datebuf, "%d-%02d-%02d %02d:%02d:%02d", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec); } }
SW1, 2の状態変化に対応する処理の記述
タクトスイッチSW1, 2がそれぞれ押されると後述の割り込みハンドラが呼ばれ,その中でbool型変数sw1Pushedとsw2Pushedがそれぞれtrueになります。loop関数内ではsw1Pushedとsw2Pushedを確認し,もしtrueであり,かつ,現在,SW1, 2が押されていなければ,それぞれの処理を実行します。SW1が押された場合,表示モードを1つ進めます(前述のようにモード1 ~ 4があります)。SW2が押された場合はWiFiへの再接続を試みるため,connectWiFi関数を呼びます。
// Toggle display mode if switch pushed, clear LCD if (sw1Pushed) { // Toggle only if current GPIO_SW1 status is HIGH if (digitalRead(GPIO_SW1) == HIGH) { sw1Pushed = false; lcd.clear(); scrMode = (scrMode + 1) % 4; } } if (sw2Pushed) { // Toggle only if current GPIO_SW1 status is HIGH // Reconnect to WiFi if (digitalRead(GPIO_SW2) == HIGH) { sw2Pushed = false; lcd.clear(); connectWiFi(); } }
それぞれの処理の後,sw1Pushedとsw2Pushedをfalseに戻します。このような処理にすれば,タクトスイッチのチャタリングを気にしなくて済みますね。
温湿度・気圧の読み取り
Adafruitのライブラリがあるためこれは非常に簡単であり,下記のみの記述となります。なお,前記事ではボタン電池の消費電力を削減するため,BME280をForced Modeで使用しましたが(0.5秒に1回だけ測定),Adafruitのライブラリにそのような機能があるのか分からなかったため,Normal Modeで使用しています(調べてみるとForced Modeも可能なようです)。
// Read from BME280, recording into global variables t = bme.readTemperature(); h = bme.readHumidity(); p = bme.readPressure() / 100.0;
キャラクタLCDへの表示
読み取った温度,湿度,気圧や現在時刻,IPアドレスなどを表示モードにしたがってキャラクタLCDに表示します。ここでは自作ライブラリST7032.hppを使用しています。switch-case文で表示モードを判定して,表示内容を変えています。ここでは表示モード1 (scrMode = 0)の場合のみを例として以下に示します。
// Show on LCD switch (scrMode) { // Show air conditions case 0: // Show on LCD, temperature lcd.setCursor(0x00); lcd.putString(String(t, 2)); lcd.putChar((char) 0xdf); lcd.putString("C "); // Show in LCD, humidity lcd.setCursor(0x0a); lcd.putString(String(h, 2) + "% "); // Show in LCD, pressure lcd.setCursor(0x40); lcd.putString(String(p, 2) + " hPa "); break; // Show clock case 1: (中略) break; // Show IP address case 2: (中略) break; // Show date, time, and air conditions case 3: (中略) }
1分毎のGoogle スプレッドシートへの記録
現在時刻の分に相当するtm.tm_minを監視し,前回のloop関数実行時から変化があった場合,Google スプレッドシートに記録するためのタスクにxTaskNotify関数にて通知を送ります。
// Post to Google Spreadsheet every minute if (online) { if (tm.tm_min != tm_min_old) { // Notify task to post to GSS xTaskNotify(taskHandle_GSS, 0, eIncrement); // Preserve minute value tm_min_old = tm.tm_min; } } // Show mark on LCD when posting to Google Spreadsheet if (scrMode != 3) { lcd.setCursor(0x4f); if (postingNow) { lcd.putChar('G'); } else { lcd.putChar(' '); // Space } }
また,Google スプレッドシートへの記録がそのタスクで進行中であるか否かをpostingNowという変数でやり取りします。もし進行中であれば,キャラクタLCDのもっとも右下(カーソル位置0x4f)に「G」を表示します。
ウェイト
loop関数の最後で25 msのウェイトを入れております。loop関数内の各処理が何msを要するのか実測しておりませんが,時計の「秒」を表示するにあたって,loop関数の実行開始のタイミングが毎正秒(時刻の秒が変化したタイミング)で始まるように設定するのが困難でした。このため,力技ですがこのように1秒に対して十分に高速なloop関数の繰り返しになるようにして,毎度キャラクタLCDへの表示を更新するようにしています。
将来的には(前々回の記事のように)温度,湿度,気圧の測定やキャラクタLCDへの表示もFreeRTOSのタスクとし,正確なインターバルで実行するように変更しようかと考えているところです。
// Wait delay(25);
connectWiFi関数
connectWiFi関数では,SSIDとPASSWORDという配列(char型の配列の配列)にそれぞれ格納されたSSIDとパスワードを使用してWiFiへの接続を試みます。また,接続を試行している過程をキャラクタLCDに表示します。スタートアップ部にSSIDとPASSWORDをグローバル定数として定義・初期化していますが,将来的には(KiCadでプリント基板として再設計する?)microSDカードに格納されたSSIDとパスワードを使うように変更したいですね。
void connectWiFi() { for (int i = 0; i < N_SSID; i++) { // Wait for connection uint32_t tWiFi = millis(); WiFi.begin(SSID[i], PASSWORD[i]); lcd.setCursor(0x00); lcd.putString("WiFi:"); lcd.putString(SSID[i]); lcd.setCursor(0x40); lcd.putString("Connecting"); while (WiFi.status() != WL_CONNECTED) { delay(T_DELAY * 2); lcd.putString("."); if (millis() - tWiFi > T_WIFI_TIMEOUT) { lcd.setCursor(0x40); lcd.putString("Failed! "); delay(T_DELAY); lcd.clear(); break; } } // Confirm connectionm, exit loop if connected if (WiFi.status() == WL_CONNECTED) { online = true; lcd.putString("OK!"); delay(T_DELAY); // Show IP address uint32_t ip = WiFi.localIP(); int ip_octet_1 = (ip & 0x000000ff); int ip_octet_2 = (ip & 0x0000ff00) >> 8; int ip_octet_3 = (ip & 0x00ff0000) >> 16; int ip_octet_4 = (ip & 0xff000000) >> 24; char buf[32]; sprintf(buf, "%d.%d.%d.%d", ip_octet_1, ip_octet_2, ip_octet_3, ip_octet_4); lcd.clear(); lcd.setCursor(0x00); lcd.putString("IP:"); lcd.putString(buf); lcd.setCursor(0x40); lcd.putString("RSSI:"); lcd.putString(String(WiFi.RSSI()) + " dBm"); delay(T_DELAY); lcd.clear(); break; } } }
postGSS関数
postGSS関数は,時刻,温度,湿度,気圧をGoogleスプレッドシートに記録する関数です。また,前述のようにこの関数はsetup関数の中でFreeRTOSのタスクとして設定されております。変数を宣言した後,while (true)で無限ループを作り,その冒頭でxTaskNotifyWait関数を呼んで,通知が送られてくるのを待ちます。
通知を受けると,タスクが進行中であることを表す変数postingNowをtrueとし,Google スプレッドシートのAPIにPOSTするためのJSONを生成します。時刻,温度,湿度,気圧はグローバル変数なので,それらを単にJSONのデータとしてセットし,シリアライズします。シリアライズ結果を文字列(char型配列)であるpubMessageに格納し,それをGoogle スプレッドシートのAPIのURLにPOSTします。
void postGSS(void *pvParameters) { uint32_t ulNotifiedValue; char pubMessage[128]; while (true) { // Wait for notification xTaskNotifyWait(0, 0, &ulNotifiedValue, portMAX_DELAY); // Flag up postingNow = true; // Create JSON message StaticJsonDocument<500> doc; JsonObject object = doc.to<JsonObject>(); // Stuff data to JSON message and serialize it object["datetime"] = datebuf; object["temp"] = t; object["humid"] = h; object["press"] = p; serializeJson(doc, pubMessage); // Post to Google Spreadsheet API http.begin(apiURL); httpCode = http.POST(pubMessage); // Flag down postingNow = false; // Wait delay(100); } }
Google スプレッドシート側ではGoogle App Scriptを準備しておき,POSTされてきたJSONから時刻,温度,湿度,気圧のデータを取り出してスプレッドシートの新しい行として追加したり,グラフを更新したりする処理を行います。Google App Script側については,本ブログの過去の記事で述べておりますので割愛させて頂きます。
onSW1関数とonSW2関数
これら2つの関数は,タクトスイッチが接続されているGPIOへの入力が立ち下がった場合(つまり,タクトスイッチが押された場合)の割り込みハンドラとしてsetup関数内で設定しています。当該部分を再掲します。
// Attach ISR for push switches attachInterrupt(GPIO_SW1, onSW1, FALLING); attachInterrupt(GPIO_SW2, onSW2, FALLING);
onSW1関数とonSW2関数は割り込みで呼ばれるので,その中での処理は極力短くしています。それぞれグローバル変数であるsw1Pushedとsw2Pushedというbool型の変数にtrueをセットしているのみです。sw1Pushedとsw2Pushedは前述のようにloop関数内でチェックされ,trueになっていればそれぞれに相当する処理を実行して,falseに戻すようにしています。
void IRAM_ATTR onSW1() { sw1Pushed = true; } void IRAM_ATTR onSW2() { sw2Pushed = true; }
まとめ
以上,Seeed Studio XIAO ESP32C3とBosch Sensortec BME280を用いた空気モニタのハードウェアとソフトウェアについて述べました。ダイソーのディスプレイキューブLと同Mは秋月電子通商の45基板にピッタリですので,他の工作にも活かせそうですね。また,Blenderと3DプリンタにてXIAOのプラプラしているシート状のアンテナを固定するパーツを作りました。
ソフトウェアとしては以前から使用している関数や処理の寄せ集めではあるのですが,本ブログに記載したことがないものが多かったため,まとめて説明することにしました。Google スプレッドシートは1分毎の頻度であれば,うまくデータとグラフの更新ができるようです*3。
さて,筆者は最近になってようやくKiCadの使い方を学び,JLCPCBに基板発注する経験を得たので,十字配線ユニバーサル基板を使う機会が減るかもしれません。次回は,前回ご紹介したPIC16F18326とBME280による気圧計の基板をKiCadで設計し,JLCPCBに発注した経緯を書きたいと思います。