前回までの記事で、HomeKitから操作できる石油ファンヒーターを作りました。HomeKitから「延長」ボタンの自動化と「運転」ボタン操作ができるファンヒーターです。今回は、これをMatter対応させます。Matterなので、HomeKitだけでなく、Amazon、Googleのエコシステムからも使えるはずです。
前回までの取り組み
このところ、冬になると石油ファンヒーターの改造をしてました。石油ファンヒーターを使用すると3時間で停止してしまうので、これを回避する改造がしたかったのが発端です。
まず一昨年の冬に、石油ファンヒーターの「延長」ボタンを改造したいと考えました。そこで、Seeed Studio XIAO ESP32C3を組み込んで、「延長」ボタンを自動で押す改造をしました。さらにHomeKitから、自動延長のOn/Offをできるように設定しました。HomeKitに接続するために、MosquittoとHomebridgeを使ってます。

昨年の冬は、買い足したファンヒータをさらに改造しました。今度はESP-01を組み込み、自動延長だけでなく、運転もOn/OffできるHomeKit対応石油ファンヒータを作りました。これもMosquittoとHomebridgeを使いました。



そしてこの冬は、ESP32-C3を使って、Matter対応の石油ファンヒータを作ります。目指す機能は、昨年版と同じく、自動延長と運転のOn/Offです。ESP32-C3を使うので、BLE (Bluetooth Low Energy) を使ったコミッショニング(初期設定)も可能なはずです。
※燃焼機構を持つ暖房器具を遠隔操作する改造ですし、製品取説には分解・改造を行わないよう警告されています。DIYする際には自己責任でお願いします。
CPUボードを選ぶ
1台目のファンヒーターでは、Seeed Studio XIAO ESP32C3を使いました。2台目は、ESP01でした。3台目(1台目を再改造します)の今回は、Matter対応を目指しますので、性能的にESP32が必要です。さらに、ESP32-S3, C3, C5, C6ならば、BLEコミッショニングも可能です。詳細は以下をご覧ください。

手元にあるSeeed Studio XIAO ESP32C3ならば、技適マークもついているのですが、今は石油ファンヒータ1号機に組み込まれていて、延長ボタン自動化で活躍中です。なので、別のものを試してみようと考えました。
そこで、AliExpressのこちらのページにあるESP32-C3 Sumer Mini PLUSというのを使ってみることにしました。569円で、送料は多分300円で、1,500円以上購入で送料無料です。今回は、ESP32-H2(Matter over Threadが’作れます)なども買ったので、送料無料になりました。

Seed Studio XIAO ESP32C3の価格に比べて半額ですが、500円くらいの違いですし、技適のあるSeed Studio XIAOの方が良かったかもしれません。ただSeed Studio XIAOにはオンボードLEDが無いので、配線が面倒だったかもしれません。
Arduino IDEの設定
Espressif社提供の「ESP32 by Espressif Systems」ボードマネージャーを導入すれば、Arduino IDEでプログラム可能です。ボードメニューの選択肢としては、あまり考えずに「ESP32C3 Dev Module」を選びました。完成した後になって、メニューのずっとしたの方に「MakerGO ESP32 C3 SuperMini」という選択肢があるのに気づきました。「ESP32C3 Dev Module」で問題なく開発できましたし、メニューの上の方にあって選択しやすいので、これで良かったと思います。
デバッグのためにシリアルポートを使うことになります。ESP32-C3 Sumer Mini の場合、シリアルを使うためには、Arduino IDEのTools/USB CDC On BootメニューをEnabledにする必要があります。AliExpressの販売ページに書かれていました。(もしかしたらボード設定をMakerGO ESP32 C3 SuperMiniにしておくと設定不要だったのかもしれません)

また、Matterを使うためには、4MBフラッシュだとギリギリです。なので、Partition SchemeをMinimum SPIFFSにしました。

回路を設計する
回路は、前回の記事とほぼ同様です。ただ、フォトスイッチの部品が手元になかったので、安価なフォトカプラーTLP621だけで作りました。
回路図を下に示します。FritzingにESP32-C3 Super Miniの部品データが無かったので、図中では無印ESP32で流用してます。点線より右側は、ファンヒータ側の回路で、そちら側の抵抗値やLED型番は適当に想像で書いてあります。

3個のTLP621(2個入りパッケージ1個と1個入りパッケージ1個)を使用しています。前回はフォトスイッチを使用したので、出力スイッチ側の極性は適当で良かったのですが、今回はフォトトランジスタを1個だけ使ったフォトカプラーです。なので、ファンヒータースイッチの極性に注意する必要があります。
これで、ESP32C3のGPIOピンが、
- GPIO0: 運転状態LEDからの入力(内部プルアップ時、LED点灯でLOW)
- GPIO1: 「運転」ボタンスイッチへの出力(HIGHで運転スイッチOn)
- GPIO2: 「延長」ボタンスイッチへの出力(HIGHで延長スイッチOn)
に接続され、プログラムからアクセスできるようになりました。
配線する
これらの部品を、ユニバーサル基板の上で配線します。その際に、グラフィックスソフトで部品の配置を考えながら配線しました。作図のグリッドを部品の足間隔にして、実際の部品の写真を使って配置を考えると、イメージを掴みやすいです。

完成した基板が以下です。

ファンヒーターに組み込む
完成した基板を、ファンヒーターのボタンパネル内に組み込みます。フォトカプラーTLP621の線は、「延長」ボタンと「運転」ボタンの両端に接続します。前回使用したフォトスイッチと違い、TLP621には極性があり、一方向にしか電流が流れません。
そこで、動作中のファンヒーターの「延長」「運転」ボタンの両端にテスターを当てて、電圧測定しました。その結果、どちらのボタンにも直流1.8V程度の電圧がかかってました。高電圧側に+マークを、低電圧側に-マークをつけました。下の写真の左の丸が「運転」ボタン、その右が「延長」ボタンです。この+/-を、TLP621のフォトトランジスタのコレクタ/エミッタ側に接続します。

運転LEDに関しては、前回と同様に、抵抗を取り付けて、線を引き出します。前回、ESP01を使った時の写真を下に示します。

ということで、今回はこのような配線になりました。

今までと違って、ESP32C3の部品面がファンヒーターの正面側に来るように設置しました。なので、写真には配線面が見えてます。というのは、後述するように、Matterのコミッショニング状態を、ESP32C3のオンボードLEDの点滅で知らせようと考えたからです。上の写真の配置では、オンボードLEDは、ファンヒーター正面を向いています。そこで、ファンヒーター正面部分に、ドリルで小さな穴を開けました。

運転ボタンの右側に小さな穴があり、ESP32C3の電源表示青色LEDが見えてます。ほぼ同じ場所に、白色LEDがありますので、白色LED点滅をファンヒーター前面から見ることができます。
プログラムを作る
このESP32C3に以下のプログラムを書き込みました。Espressifが公開しているMatter OnOff Pluginサンプルをもとにして作りました。詳しい説明は以下の記事をご覧ください。下記ではOnOffLightを使ってますが、動作は同じです。
https://diysmartmatter.com/archives/5332
今回は、エンドポイントとして、MatterOnOffPluginを2個有するデバイスとしています。プログラム中でOperateSwとExtendSwと名付けた2つのエンドポイントです。それぞれが、ファンヒーターのOn/Offと、自動延長のOn/Offを担当します。
// Matter controlled Fanheater. 2025.12
// Uses ESP32 C2 SuperMini
// Fan Heater flag
bool ignoreCallback=false; //true: don't toutch GPIO in Matter callback
//Extend time conrtol
const uint32_t extend_period = 150 * 60 * 1000; //150 min in ms
uint32_t extend_start; //for time keeping
//Pin assignment
const uint8_t commissionLED = LED_BUILTIN; // on board
const uint8_t operateBTN =1; // photo coupler to operate button
const uint8_t operateLED =0; // photo coupler from operate LED
const uint8_t extendBTN =2; // photo coupler to extend button
// Matter Manager
#include <Matter.h>
// List of Matter Endpoints for this Node
// On/Off Plugin Endpoint
MatterOnOffPlugin OperateSw;
MatterOnOffPlugin ExtendSw;
//click button for 300ms connected to the pin
void clickBTN(int gpiopin ){
digitalWrite(gpiopin, HIGH); //turn on photo coupler
delay(300);
digitalWrite(gpiopin, LOW); //turn off photo coupler
}
// Matter Protocol Endpoint Callback
bool setOperateSw(bool state) {
Serial.printf("User Callback :: New OperateSw State = %s\r\n", state ? "ON" : "OFF");
if( ignoreCallback ) ignoreCallback=false;
else clickBTN(operateBTN);
// This callback must return the success state to Matter core
return true;
}
bool setExtendSw(bool state){
Serial.printf("User Callback :: New ExtendSW State = %s\r\n", state ? "ON" : "OFF");
clickBTN(extendBTN); //extend once anyway
extend_start=millis(); //set extend start time
return true;
}
// commission my device. this blocks until commissioned (may be forever).
void commissionMyMatter() {
Serial.println("");
Serial.println("Matter Node is not commissioned yet.");
Serial.println("Initiate the device discovery in your Matter environment.");
Serial.println("Commission it to your Matter hub with the manual pairing code or QR code");
Serial.printf("Manual pairing code: %s\r\n", Matter.getManualPairingCode().c_str());
Serial.printf("QR code URL: %s\r\n", Matter.getOnboardingQRCodeUrl().c_str());
// waits for Matter Plugin Commissioning.
uint32_t timeCount = 0;
while (!Matter.isDeviceCommissioned()) {
delay(100);
if ((timeCount++ % 10) == 0) { // 10*100ms = 1 sec
digitalWrite(commissionLED,digitalRead(commissionLED)==HIGH ? LOW : HIGH);
}
if ((timeCount++ % 150) == 0) { // 150*100ms = 15 sec
Serial.println("Matter Node not commissioned yet. Waiting for commissioning.");
}
}
digitalWrite(commissionLED, LOW);
}
void setup() {
pinMode(operateBTN,OUTPUT); //to fanheater's operate btn
pinMode(extendBTN,OUTPUT); //to fanheater's extend btn
pinMode(operateLED,INPUT_PULLUP); //from fanheater's operate LED
digitalWrite(operateBTN,LOW); //LOW: photo coupler off
digitalWrite(extendBTN,LOW); //LOW: photo coupler off
Serial.begin(115200);
// set initial value and callback to each endpoint
OperateSw.begin(false);
OperateSw.onChange(setOperateSw);
ExtendSw.begin(false);
ExtendSw.onChange(setExtendSw);
// Matter beginning - Last step, after all EndPoints are initialized
Matter.begin();
if(Matter.isDeviceCommissioned()){ // If it is commissioned in previous run.
OperateSw.updateAccessory(); // configure the endpoint
ExtendSw.updateAccessory();
Serial.println("Matter Node is commissioned and connected to the network. Ready for use.");
} // else, commissioning shall be done in the first loop()
}
//detect commission request triggered by click sequence of a button or LED
//currently 6 clicks/blinks trigger commission process
//returns true when trigger sequence is detected
//input: value of the button or LED
#define STEPS 15 //size of the pattern array
// pattern = -_-_-_-_-_-----
int pattern[STEPS]={HIGH,LOW,HIGH,LOW,HIGH,LOW,HIGH,LOW,HIGH,LOW,HIGH,HIGH,HIGH,HIGH,HIGH};
// (STEPS - 11)+1 clicks or blinks of button or LED trigger the commissioning
int lastvalue=HIGH; //previous value of the button/LED
bool isPatternStart=false; //shows if pattern matching has been started
int step; //matching step. should be 0 to STEPS-1
uint32_t start_time; //pattern interval (500ms) start time
bool isCommissionRequest(int newvalue){
if(!isPatternStart){
if(lastvalue==LOW && newvalue==HIGH) {
isPatternStart=true;
step=0;
start_time = millis(); //set checkpoint timer
}
lastvalue=newvalue;
return false; //no LOW->HIGH, do nothing
}
//if isPatternStart==true
if((millis() - start_time) < 500 ) return false; //too early, do nothing
start_time = millis(); //reset timer
//check pattern
if(newvalue != pattern[step]) {
isPatternStart=false; //startover
return false; //out of pattern
}
//pattern matches for this step
Serial.printf("Commission request pattern matched by %d.\r\n", step);
if(step < STEPS - 1) { //not yet the last step
step++;
return false;
}
return true;
}
void loop() {
// Check Commissioning state, which may change during execution of loop()
if (!Matter.isDeviceCommissioned()) {
commissionMyMatter();
OperateSw.updateAccessory(); // configure the Plugin based on initial state
ExtendSw.updateAccessory();
Serial.println("Matter Node is commissioned and connected to the network. Ready for use.");
}
uint32_t time_now=millis();
int currentLED=digitalRead(operateLED); //LOW at LED-on
// check commissioning reequest by operateLED
if(isCommissionRequest(currentLED)) {
Serial.println("Decommissioning the Plugin Matter Accessory. It shall be commissioned again.");
Matter.decommission();
}
// Update operation status by reading operateLED
static uint32_t time_ledon;// last time when operate LED was on
if(currentLED==LOW){ // if LED is on, it is operating
time_ledon=time_now; // last time when the LED on
if(!OperateSw.getOnOff()){ // if not operating, physical btn was used
ignoreCallback=true; // request not to toutch GPIO in next callback
OperateSw.setOnOff(true); // update Matter to reflect physical world
}
}else{ // if LED is off, it is NOT operating
if(time_now - time_ledon > 2000 ) // if off more thhan 2s, and
if(OperateSw.getOnOff()){ //if it is operating, physical btn was used
ignoreCallback=true; // request not to toutch GPIO in next callback
OperateSw.setOnOff(false); // update Matter to reflect physical world
}
}
// keep extending fanheater operation when ExtendSw is on
if(ExtendSw.getOnOff() && ((time_now - extend_start)>extend_period)) {
extend_start = time_now;
clickBTN(extendBTN);
}
}
プログラムの内容を簡単に説明します。
bool型フラグとして、ignoreCallbackというフラグを用意しました。Matterプログラミングでは、setup()関数の中で、onChange()メソッドを使って、コールバック関数を指定しています。エンドポイントの状態が変更した場合に呼び出される関数です。例えばiPhoneから運転スイッチの状態が変更されると、コールバック関数が呼ばれます。そこでコールバック関数では、ファンヒーターの運転スイッチを短押しして、状態を変更します。
ただ、このコールバック関数は、プログラムの中でエンドポイントの状態を変更した場合も呼ばれてしまいます。例えば、ファンヒータの電源LEDの点灯消灯が変更された場合、ユーザが物理スイッチを押してOn/Offしたと判断して、プログラムの中でエンドポイントの状態を更新します。その場合にもコールバック関数が呼ばれてしまうので、そこでファンヒーターの運転スイッチを短押しすると、再び状態を変えてしまうことになります。運転スイッチがトグル動作していることが根本の原因です。
なので、次回、コールバック関数が呼ばれても、運転スイッチをトグルして欲しくない場合に、このignoreCallbackフラグをtrueにして使います。
コミッショニングを起動させる方法に関しては、少し工夫しました。コミッショニング開始用のスイッチを追加で取り付けるのが、プログラムとしては簡単ですが、ファンヒーターに新しいスイッチを加工して取り付ける事が面倒でした。ファンヒーターは、電源投入後に1-2分の予熱状態に入ります。この時、運転LEDは、1秒周期で点滅します。そこで、「LEDが6回点滅したところで運転停止したらコミッショニング開始する」ようにプログラムすることにしました。これなら追加のスイッチは不要です。
「運転LEDが6回点滅する」ことを検出しているのが、プログラムの中のisCommissionRequest(int newvalue)関数です。引数は、現在の運転LEDの状態です。「運転LEDが6回点滅する」と、trueが返されます。それ以外はfalseを返します。パターンマッチングなのでプログラムが面倒でしたが頑張って考えました。この関数は、loop()の中で呼び出されます。trueになると、Matter.decommission()を呼んで、コミッショニング開始を促します。
そのため、loop()が稼働している最中に、コミッショニングが必要になることがあります。そこでloop()の中で、Matter.isDeviceCommissioned()を調べて、必要に応じてコミッショニングをしています。コミッショニングは、commissionMyMatter()関数で行ってます。ここで、コミッショニング中に、ESP32C3搭載のLEDを点滅させてます。
ファンヒータの運転を延長するために、loop()の中でExtendSwの状態を読んで、これがOnなら150分ことに延長ボタンを押すようにしています。
実際の石油ファンヒータの動作状態は、運転状態表示LEDを読んで設定してます。LEDが点灯すると、運転状態と判定します。LEDが消灯した場合は、1秒以上継続して消灯していた場合に運転停止状態と判定します。運転準備、停止準備などの場合に、LEDが1秒周期で点滅する場合があり、これを運転状態とみなすために、このようにプログラムしました。
設定用QRコードを取得
このプログラムを最初に動作させると、コミッショニングモードになります。シリアルモニタには、設定用のQRコードへのURLが表示されます。それを開くと、以下のようなページが表示されます。

このQRコードを使って、Matter製品に付属しているような設定用ラベルを印刷して、石油ファンヒーターの灯油タンク蓋の裏に貼っておきました。石油ファンヒーターがMatter対応デバイス風になってきました。

HomeKitに追加
これでMatter対応石油ファンヒーターが完成しました。Matterなので、すぐにHomeKitに追加できます。iPhoneのホームの右上の+マークから「アクセサリを追加」を選びます。
一方でMatterデバイス側も、コミッショニング状態にしておきます。最初にプログラムを書き込んだ状態では、コミッショニング状態になります。「運転LED」6回点滅でコミッショニングするようプログラムしてあるので、その方法でも良いです。デバッグ段階でしたら、Arduino IDEで、Erase All Flash Before Sketch UploadをEnabledにすると、プログラムをアップロードする際にフラッシュメモリが消去され、コミッションモードになります。製品で言うならば、工場出荷時状態ですね。

iPhoneのホームの右上の+マークから「アクセサリを追加」を選び、カメラから設定QRコードを読み込ませれば、コミッショニングが実施されます。コミッショニング中は、iPhoneとBLE で通信します。距離を近づけてあげる必要があります。何度か失敗しましたが、そのうち成功しました。
これでMacやiPhoneのホーム.appに、石油ファンヒーターのアクセサリが表示されました。デフォルトではプラグとして表示されるので、ファンに変更しました。

エンドポイントを2個有しているので、これをクリックするとそれらが表示されます。

On/Offスイッチと、自動延長スイッチが表示されてます。自動延長スイッチをOnにすると、150分ごとに延長スイッチが押されます。
一方、On/Offスイッチを押すと、石油ファンヒーターの運転をOn/Offできます。その様子を動画撮影しました。予熱期間があるのでしばらく時間がかかりますが、27秒あたりで、ボッという音と共に点火します。
通電すると運転開始する
ほぼ計画通りに、安定して動作しています。ただ、今回の改造後、ファンヒーターをコンセントに挿して通電すると、即時に運転開始してしまうようになりました。改造以前は、コンセントに插すだけでは運転開始せず、運転ボタンを押して初めて運転開始しました。ESP32C3の初期化段階で、GPIOが一時的にHIGHになるようです。
原因は不明です。「運転」スイッチのフォトカプラーを、電圧出力モードでOnにしているので、それが原因かと最初は思いました。ESP32は、setup()の中で、pinMode(operateBTN,OUTPUT_OPEN_DRAIN)と初期化するとオープンコレクタ方式の出力になります。それで、回路を変更してオープンコレクタにしてみましたが、通電直後に運転開始してしまう現象に変わりありませんでした。
通電直後の運転開始を望まないならば、運転ボタンを押せばすぐに停止するので、大した問題ではありません。なので現状では未対応です。
まとめ
ESP32C3とフォトカプラーを石油ファンヒーターに組み込み、運転On/Offと、自動延長On/Offの設定をMatterから行えるようにしました。BLE経由でのコミッショニングも可能です。これで、Matter対応石油ファンヒーターが完成しました。
Matter対応とはいっても、On/Offできるだけで、温度設定などはできません。でもiPhoneをリモコンがわりに電源入れられるだけでも、かなり便利です。
遠隔地からファンヒーターを運転できてしまう危険な改造ではありますが、遠隔地からファンヒーターの動作状態を確認することもできます。なので消し忘れに気付いて消すこともできます。また、偶発的に点火したとしても、灯油がなくなればいずれは停止します。電熱ストーブを自動化するよりは安全と言えるかもしれません。
Matter対応なので、HomeKitだけでなく、AmazonやGoogleのエコシステムにも登録可能なはずです。そのうち試したいと思います。



コメント