憧れのドラム式洗濯機を買いました
私事で恐縮ですが昨年の4月、洗濯機を憧れのドラム式に買い替えました。
PanasonicのNA-LX125ARという機種で、もう手放せないと思えるくらいには非常に便利。
(↓すでに二世代後継機の125CLが出てます…)
一方で不満点が無いわけではありません。
ズボラなので毎回乾燥まで一気にかけたいのですが、縮むと困る衣類等は脱水完了~乾燥開始の間に取り出したいのです。
また、乾燥後取り出すのを忘れて翌日、ということもままあります、
NA-LX127/NA-LX129といった上位機種であればスマホ通知があるようなのですが、ケチってNA-LX125にしたためその機能はありません。
負け惜しみとして、スマホ通知より気付きやすいAlexaに終了通知を喋ってもらうシステムを構築します。
目標
今回のやりたいことを実現するにあたって、制約を設けました。
・消費電力は非接触で測定する
家庭用のコンセントの100V回路に素人作成デバイスを噛ませると最悪火事が起こり得ますので、CTセンサを使用して非接触で測定します。
精度は落ちるかもしれませんが、正確な数字が取れなくても傾向さえ取れればよいので問題ありません。
・マイコンのみで、ローカルで動作する
Raspberry PiでHomeAssistantやHomeBridgeなどスマート化のソリューションがありますが、今回は使用しません。
現状他の機器で使っていないので単にコストの問題です。
また、なるべくクラウド依存を減らすために最近話題のMatter規格準拠で作成し、ローカルで動作させます。
材料
・XIAO ESP32C3
ESP32系のボードであれば何でもよいです。
フットプリントが小さいので個人的お気に入りです。
・クランプ型 交流電流センサ(CTセンサ)
今回はこちらを使用しましたが、ドラム式洗濯機の最大消費電力が600W前後のため10A程度のレンジのものを選ぶとよいでしょう。
・1個口 延長コード
CTセンサを間に噛ませるために使用します。なるべく短いと使いやすいと思います。
・10KΩ抵抗2個 10Ω抵抗1個
CTセンサの出力は正負電圧のため、抵抗分圧で仮想中点を作ります。
・I2C OLED液晶(オプション)
なくても良いですが、あるとデバッグが便利でした。
交流電流を非接触で測定する
クランプメーターで測定できるのは交流電流のみのため、正しい消費電力を測定するには電圧の測定も必要になります。
前述通り今回は正確なものでなくてよく、傾向が取れればよいので下記の記事を参考にさせていただき、電圧は100V一定として測定電流を時間積分して求めます。
回路図
先程の記事での回路図をほぼ引用し、XIAO ESP32C3用に若干変更したものになります。
コンデンサがついていませんがとりあえず動作はしました。
オプションのOLED液晶を付ける場合はXIAOESPC3のD4/D5にOLEDのSDA/SCLをそれぞれ接続してください。
ドラム式洗濯機の消費電力を測定する
上記回路、上記記事のコードで実際にNA-LX125の洗濯・脱水・乾燥まで一気にかけた時の消費電力の波形がこちらです。
脱水中は200W一定、乾燥中は600W一定、のような感じであればよかったのですが、脱水中に結構消費電力が高いポイントがあり単純に判定をかけるのは難しそうです。
また、洗濯だけ/乾燥だけの時も考慮して、ルールベースで判定するようにします。
具体的には「250W以上検知を2分開けて3回検知したら脱水中と判断」、といった感じです。
なお、このデータは60Hz地域で測定したもので、同じ60Hz、同じNA-LX125、同じCTセンサなど同じ環境であれば流用できるかと思いますが、参考値程度にさせてください。
Matter対応
続いてMatter対応部分ですが、Matter上では仮想的な接触センサとして扱ってもらい、脱水完了で1個目の接触センサをOPEN、乾燥完了で2個めの接触センサをOPENといった動作にします。
接触センサの開閉はAlexaの定型アクションをトリガーできるため、このような悪用(?)をします。
詳しくは自分の過去記事を参考にしてください。
コード
ではコードになります。
OLEDを使用しない場合は該当箇所をコメントアウトなり削除なりしてください。
また、arduino core用のMatterライブラリはコンパイルするために設定が必要です。
ライブラリのreadmeを参照してください。
#include "Matter.h"
#include <app/server/OnboardingCodesUtil.h>
#include <credentials/examples/DeviceAttestationCredsExample.h>
using namespace chip;
using namespace chip::app::Clusters;
using namespace esp_matter;
using namespace esp_matter::endpoint;
#define CT_SENSOR_PIN     A2
#define VREF 3.3
#define ANALOG_MAX 4096
#define MODE_JUDGE_COUNT 20 //20秒ごとにモード確認
#define TIMEOUT          60 //洗濯中からタイムアウト用 20x60 = 1200 20分で強制タイムアウト
#define SPIN_COUNT_TH    3 //洗濯中から脱水遷移用
#define DRY_COUNT_TH     60 //乾燥モード判定用 20 x 6 = 120 20分300W以上継続で判定
#define CONTACT_SENSOR_STATE_OPEN false
#define CONTACT_SENSOR_STATE_CLOSE true
#include <Wire.h>
#include "SSD1306Wire.h"
// for 128x64 displays:
SSD1306Wire display(0x3c, D4, D5);  // ADDRESS, SDA, SCL
// XIAO ESP32C3のBOOTボタンはGPIO9、devkit-CなどはGPIO0を使用する
const int TOGGLE_BUTTON_PIN = 9;
// Debounce for toggle button
const int DEBOUNCE_DELAY = 500;
int last_toggle;
// Cluster and attribute ID used by Matter light device
const uint32_t CLUSTER_ID = BooleanState::Id;
const uint32_t ATTRIBUTE_ID = BooleanState::Attributes::StateValue::Id;
// Endpoint and attribute ref that will be assigned to Matter device
uint16_t wash_finish_endpoint_id = 0;
uint16_t dry_finish_endpoint_id = 0;
attribute_t *wash_finish_attribute_ref;
attribute_t *dry_finish_attribute_ref;
// 電流計測用変数
volatile unsigned short integral_counter = 0;
volatile unsigned short mode_judge = 0;
volatile unsigned short timeout_counter = 0;
volatile unsigned short spin_counter = 0;
volatile unsigned short mask_counter = 0;
volatile unsigned short dry_counter = 0;
volatile unsigned short trans_counter = 0;
volatile unsigned short analogPinStatus;
volatile double ammeter;
volatile double ammeter_v;
const double dt = 0.001;
volatile double ammeterSum = 0;
volatile double watt = 0;
const double voltage = 100.0;
volatile double watt_buffer[10] = {0};
volatile double watt_average;
const double CURRENT_OFFSET = 2350.0;
volatile bool flag_dry_finish = false;
volatile bool flag_spin_finish = false;
volatile bool flag_ready = false;
volatile bool flag_mode_change = false;
enum en_wash_mode
{
  MODE_STANDBY,
  MODE_WASH,
  MODE_SPIN,
  MODE_TRANS,
  MODE_DRY
};
enum en_wash_mode wash_mode = MODE_STANDBY;
static const char *valid_strs[5] = 
{
  "スタンバイ" ,
  "洗濯中" ,
  "脱水中" ,
  "洗濯完了、乾燥開始待ち" ,
  "乾燥中" 
};
static const char *valid_strs_display[5] = 
{
  "OFF" ,
  "WASH" ,
  "SPIN" ,
  "WAIT" ,
  "DRY" 
};
volatile bool record_flag = false;
volatile double watt_data[10];
volatile unsigned short record_counter = 0;
volatile int timeCounter1;
volatile bool timerISR = false;
hw_timer_t *timer = NULL; 
unsigned long currentMillis, previousMillis;
const unsigned long interval = 10000;
unsigned int loop_counter = 0;
unsigned short drift_reset_counter = 0;
// There is possibility to listen for various device events, related for example
static void on_device_event(const ChipDeviceEvent *event, intptr_t arg) {}
static esp_err_t on_identification(identification::callback_type_t type,
                                   uint16_t endpoint_id, uint8_t effect_id,
                                   uint8_t effect_variant, void *priv_data)
{
  return ESP_OK;
}
// Listener on attribute update requests.
static esp_err_t on_attribute_update(attribute::callback_type_t type,
                                     uint16_t endpoint_id, uint32_t cluster_id,
                                     uint32_t attribute_id,
                                     esp_matter_attr_val_t *val,
                                     void *priv_data) 
{
  return ESP_OK;
}
//////////////////////////////////////////////////////////////////////////
//// タイマー割り込み
//////////////////////////////////////////////////////////////////////////
void IRAM_ATTR onTimer()
{
  //AD変換  
  analogPinStatus = analogRead(CT_SENSOR_PIN);
  ammeter_v = (analogPinStatus - CURRENT_OFFSET ) * VREF/ ANALOG_MAX;
  ammeter = ammeter_v / 0.12375;
  ammeterSum = ammeterSum + abs(ammeter) * dt;
  integral_counter++;
  //1秒ごとにワット算出
  if(integral_counter >= 1000)
  {
    watt = ammeterSum * voltage;
    ammeterSum = 0;
    integral_counter = 0;
    
    //移動平均
    watt_buffer[9] = watt_buffer[8];
    watt_buffer[8] = watt_buffer[7];
    watt_buffer[7] = watt_buffer[6];
    watt_buffer[6] = watt_buffer[5];
    watt_buffer[5] = watt_buffer[4];
    watt_buffer[4] = watt_buffer[3];
    watt_buffer[3] = watt_buffer[2];
    watt_buffer[2] = watt_buffer[1];
    watt_buffer[1] = watt_buffer[0];
    watt_buffer[0] = watt;
    watt_average = (watt_buffer[0] + watt_buffer[1] + watt_buffer[2] + watt_buffer[3] + watt_buffer[4] + watt_buffer[5] + watt_buffer[6] + watt_buffer[7] + watt_buffer[8] + watt_buffer[9]) / 10.0;
    
  }
  timerISR = true;
}
//////////////////////////////////////////////////////////////////////////
//// 状態遷移
//////////////////////////////////////////////////////////////////////////
void mode_update_check()
{
  if(mask_counter > 0)
  {
    mask_counter--;
  }
  if((wash_mode == MODE_STANDBY)&&(watt_average >= 30.0))//スタンバイ→30W以上検知
  {
    wash_mode = MODE_WASH;
    spin_counter=0;
    flag_ready = true;
    flag_mode_change = true;
  }
  if((wash_mode == MODE_WASH)&&(watt_average >= 250.0))//洗濯中 250W以上検知
  {
    if(mask_counter == 0)
    {
      spin_counter++;
      mask_counter = 6;//2分間反応させない
    }
    if(spin_counter > SPIN_COUNT_TH)//3回検知
    {
      wash_mode = MODE_SPIN;//脱水中と判断
      spin_counter=0;
      flag_mode_change = true;
    }  
  }
  if((wash_mode == MODE_SPIN)&&(watt_average <= 230.0))//脱水中→230W以下2回検知
  {
    trans_counter++;
    if(trans_counter > 1)
    {
      wash_mode = MODE_TRANS;
      flag_spin_finish = true;
      trans_counter = 0;
      flag_mode_change = true;
    }
  }
  else
  {
    trans_counter = 0;
  }
  if((wash_mode != MODE_DRY)&&(watt_average > 300.0))//乾燥中以外→300W以上検知
  {
    dry_counter++;
    if(dry_counter > DRY_COUNT_TH)//60回連続検知→乾燥状態と判定
    {
      wash_mode = MODE_DRY;
      dry_counter=0;
      flag_mode_change = true;
    }
  }
  else
  {
    dry_counter=0;
  }
  if((wash_mode == MODE_DRY)&&(watt_average < 10.0))//乾燥中→10W以下検知
  {
    wash_mode = MODE_STANDBY;
    flag_dry_finish = true;
    flag_mode_change = true;
  }
  if((wash_mode != MODE_STANDBY)&&(watt_average < 10.0))//スタンバイ以外→10W以下検知
  {
    timeout_counter++;
    if(timeout_counter >= TIMEOUT)
    {
      wash_mode = MODE_STANDBY;//スタンバイ以外なのに10W以下が20分続いた場合、強制的にスタンバイへする
      timeout_counter=0;
      spin_counter=0;
      flag_mode_change = true;
    }
  }
  else
  {
    timeout_counter=0;
  }
}
//////////////////////////////////////////////////////////////////////////
//// セットアップ
//////////////////////////////////////////////////////////////////////////
void setup()
{
  Serial.begin(115200);
  pinMode(TOGGLE_BUTTON_PIN, INPUT);
  display.init();
  display.flipScreenVertically();
  display.setContrast(255);
  display.setFont(ArialMT_Plain_24);
  display.setTextAlignment(TEXT_ALIGN_CENTER_BOTH);
  display.drawString(64, 32, "Initialize");
  display.display();   //指定された情報を描画
  // Enable debug logging
  esp_log_level_set("*", ESP_LOG_DEBUG);
  // Matterノードの設定、デバイス名の設定
  node::config_t node_config;
  snprintf(node_config.root_node.basic_information.node_label, sizeof(node_config.root_node.basic_information.node_label), "Drum Wash Bridge");
  node_t *node = node::create(&node_config, on_attribute_update, on_identification);
  // 接触センサのendpoint / cluster / attributesをデフォルト値でセットアップ
  // 接触センサのRequirement clusterはidentifyとboolean_state
  // デフォルト値なのでcontact_sensor.create()でも行けると思われる
  contact_sensor::config_t contact_sensor_config;
  contact_sensor_config.boolean_state.state_value = CONTACT_SENSOR_STATE_CLOSE; //センサ認識状態の初期値は閉にする
  contact_sensor_config.identify.cluster_revision = 4; //Matter仕様書1.0での最新リビジョンは4
  contact_sensor_config.identify.identify_time = 0;
  contact_sensor_config.identify.identify_type = 0x02; //LEDインジケータでの識別
  
  // 複数台のエンドポイント(接触センサ)を一台のESP32に乗せるので、ブリッジにぶら下げる構成にする。
  // ぶら下がるエンドポイントにはbridged_node clusterを乗せる必要がある
  bridged_node::config_t bridged_node_config;
  bridged_node_config.bridged_device_basic_information.cluster_revision = 1;
  bridged_node_config.bridged_device_basic_information.reachable = true;
  //ブリッジ(Aggregator)のエンドポイントを作成
  endpoint_t *endpoint_1 = aggregator::create(node, ENDPOINT_FLAG_NONE, NULL);
  //ブリッジにぶら下がる2つの接触センサのエンドポイントを作成して、bridged_nodeとcontact_sensorの2種類のclusterを乗せる
  endpoint_t *endpoint_wash_finish_sensor = bridged_node::create(node, &bridged_node_config, ENDPOINT_FLAG_NONE, NULL);
  contact_sensor::add(endpoint_wash_finish_sensor, &contact_sensor_config);
  endpoint_t *endpoint_dry_finish_sensor = bridged_node::create(node, &bridged_node_config, ENDPOINT_FLAG_NONE, NULL);
  contact_sensor::add(endpoint_dry_finish_sensor, &contact_sensor_config);
  //接触センサのエンドポイントにデバイス名をつける
  cluster::bridged_device_basic_information::attribute::create_node_label(cluster::get(endpoint_wash_finish_sensor, BridgedDeviceBasicInformation::Id), "Wash Finish Sensor", strlen("Wash Finish Sensor"));
  cluster::bridged_device_basic_information::attribute::create_node_label(cluster::get(endpoint_dry_finish_sensor, BridgedDeviceBasicInformation::Id), "Dry Finish Sensor", strlen("Dry Finish Sensor"));
  //接触センサのエンドポイントをブリッジに紐付ける
  set_parent_endpoint(endpoint_wash_finish_sensor, endpoint_1);
  set_parent_endpoint(endpoint_dry_finish_sensor, endpoint_1);
  // センサ状態のアトリビュートを取得
  wash_finish_attribute_ref = attribute::get(cluster::get(endpoint_wash_finish_sensor, CLUSTER_ID), ATTRIBUTE_ID);
  dry_finish_attribute_ref = attribute::get(cluster::get(endpoint_dry_finish_sensor, CLUSTER_ID), ATTRIBUTE_ID);
  // 接触センサのエンドポイントのIDを取得
  wash_finish_endpoint_id = endpoint::get_id(endpoint_wash_finish_sensor);
  dry_finish_endpoint_id = endpoint::get_id(endpoint_dry_finish_sensor);
  
  // Setup DAC (this is good place to also set custom commission data, passcodes etc.)
  esp_matter::set_custom_dac_provider(chip::Credentials::Examples::GetExampleDACProvider());
  // Start Matter device
  esp_matter::start(on_device_event);
  // Print codes needed to setup Matter device
  PrintOnboardingCodes(chip::RendezvousInformationFlags(chip::RendezvousInformationFlag::kBLE));
  //タイマー割り込み開始
  timer = timerBegin(0, 80, true);
  timerAttachInterrupt(timer, &onTimer, true);
  timerAlarmWrite(timer, 1000, true);
  timerAlarmEnable(timer);
}
//////////////////////////////////////////////////////////////////////////
//// 接触センサの状態を取得
//////////////////////////////////////////////////////////////////////////
esp_matter_attr_val_t get_boolean_attribute_value(esp_matter::attribute_t *att_ref)
{
  esp_matter_attr_val_t boolean_value = esp_matter_invalid(NULL);
  attribute::get_val(att_ref, &boolean_value);
  return boolean_value;
}
//////////////////////////////////////////////////////////////////////////
//// 接触センサの状態をセット
//////////////////////////////////////////////////////////////////////////
void set_boolean_attribute_value(esp_matter_attr_val_t *boolean_value, uint16_t endpoint_id)
{
  attribute::update(endpoint_id, CLUSTER_ID, ATTRIBUTE_ID, boolean_value);
}
//////////////////////////////////////////////////////////////////////////
//// メインループ
//////////////////////////////////////////////////////////////////////////
void loop()
{
  esp_matter_attr_val_t contact_value;
  
  if (timerISR)
  {
    timerISR = false;
    loop_counter++;
  }
  // スタンバイモード
  if(flag_ready == true)
  {
    contact_value = get_boolean_attribute_value(wash_finish_attribute_ref);
    contact_value.val.b = CONTACT_SENSOR_STATE_CLOSE;
    set_boolean_attribute_value(&contact_value, wash_finish_endpoint_id);
    contact_value = get_boolean_attribute_value(dry_finish_attribute_ref);
    contact_value.val.b = CONTACT_SENSOR_STATE_CLOSE;
    set_boolean_attribute_value(&contact_value, dry_finish_endpoint_id);
    flag_ready = false;
  }
  // 脱水完了モード
  if(flag_spin_finish == true)
  {
    contact_value = get_boolean_attribute_value(wash_finish_attribute_ref);
    contact_value.val.b = CONTACT_SENSOR_STATE_OPEN;
    set_boolean_attribute_value(&contact_value, wash_finish_endpoint_id);
    flag_spin_finish = false;
  }
  // 乾燥完了モード
  if(flag_dry_finish == true)
  {
    contact_value = get_boolean_attribute_value(dry_finish_attribute_ref);
    contact_value.val.b = CONTACT_SENSOR_STATE_OPEN;
    set_boolean_attribute_value(&contact_value, dry_finish_endpoint_id);
    flag_dry_finish = false;
  }
  
  //状態変化通知
  if(flag_mode_change == true)
  {
    //matterの状態アップデートとか
    flag_mode_change = false;
  }
  // 1sごとに積分誤差リセットチェック
  if(loop_counter % 1000 == 0)
  {
    // スタンバイモードで10W以下の状態だとカウントアップする
    if((wash_mode == MODE_STANDBY)&&(watt_average <= 10.0))
    {
      drift_reset_counter++;
    }
    else
    {
      drift_reset_counter = 0;
    }
    if(drift_reset_counter > 60)//1分間連続した場合積分誤差をリセットする。
    {
      watt_buffer[9] = 0;
      watt_buffer[8] = 0;
      watt_buffer[7] = 0;
      watt_buffer[6] = 0;
      watt_buffer[5] = 0;
      watt_buffer[4] = 0;
      watt_buffer[3] = 0;
      watt_buffer[2] = 0;
      watt_buffer[1] = 0;
      drift_reset_counter = 0;
    }
  }
  if(loop_counter % 2000 == 0)// 2sごとにOLED更新
  {
    OLED_update();
  }
  if(loop_counter >= 20000)// 20sごとに状態更新
  {
    mode_update_check();
    loop_counter = 0;
  }
  //強制リセット用
  if(digitalRead(TOGGLE_BUTTON_PIN) == LOW) 
  {
    // Key debounce handling
    delay(100);
    int startTime = millis();
    while(digitalRead(TOGGLE_BUTTON_PIN) == LOW) delay(50);
    int endTime = millis();
    if ((endTime - startTime) > 10000) 
    {
      esp_matter::factory_reset();//10秒以上押されたらファクトリーリセット
    }
    else
    {
      
    }
  }
}
//////////////////////////////////////////////////////////////////////////
//// OLED表示更新
//////////////////////////////////////////////////////////////////////////
void OLED_update()
{
  display.clear();
  display.setFont(ArialMT_Plain_24);
  String watt_display = String(watt_average, 1);
  display.setTextAlignment(TEXT_ALIGN_RIGHT);
  display.drawString(110, 5, "W");
  display.drawString(80, 5, watt_display);
    
  String mode_display = valid_strs_display[wash_mode];
  display.setTextAlignment(TEXT_ALIGN_CENTER_BOTH);
  display.drawString(64, 44, mode_display);
  display.display();
}
//////////////////////////////////////////////////////////////////////////
//// OLED更新用
//////////////////////////////////////////////////////////////////////////
void OLED_update()
{
  esp_matter_attr_val_t val_wash = esp_matter_invalid(NULL);
  esp_matter_attr_val_t val_dry = esp_matter_invalid(NULL);
  String wash_state_display;
  String dry_state_display;
  String watt_display = String(watt_average, 1);    
  String mode_display = valid_strs_display[wash_mode];
  esp_matter::attribute::get_val(wash_finish_attribute_ref, &val_wash);
  esp_matter::attribute::get_val(dry_finish_attribute_ref, &val_dry);
  
  attribute::get_val(wash_finish_attribute_ref, &val_wash);
  attribute::get_val(dry_finish_attribute_ref, &val_dry);
  if(val_wash.val.b == CONTACT_SENSOR_STATE_OPEN)
  {
    wash_state_display = "OPEN";
  }
  else
  {
    wash_state_display = "CLOSE";
  }
  if(val_dry.val.b == CONTACT_SENSOR_STATE_OPEN)
  {
    dry_state_display = "OPEN";
  }
  else
  {
    dry_state_display = "CLOSE";
  }
  
  display.clear();
  display.setFont(ArialMT_Plain_16);
  display.setTextAlignment(TEXT_ALIGN_RIGHT);
  display.drawString(55, 0,  "wash:");
  display.drawString(55, 16, "dry:");
  display.drawString(55, 32, "Watt:");
  display.drawString(55, 48, "mode:");
  display.setTextAlignment(TEXT_ALIGN_LEFT);
  display.drawString(60, 0, wash_state_display);
  display.drawString(60, 16, dry_state_display);
  display.drawString(60, 32, watt_display);
  display.drawString(60, 48, mode_display);
  display.display();
}
ペアリングおよび設定
ペアリングについても自分の過去記事を参考にしてください。
Alexaに登録すると、
・Drum Wash Bridge
・Wash Finish Sensor
・Dry Finish Sensor
の3つが登録されるかと思います。
登録後はデバイス名を日本語に変更できます。
定型アクションを2つ新規作成し、実行条件を「スマートホーム」からWash Finish SensorかDry Finish Sensorを割り当ててください。
Alexaのアクションには「Alexaのおしゃべり」→「カスタム」で喋って欲しい内容を書けばOKです。
自分の場合は深夜に喋られても困るので時間制限もしています。
動作
1秒ごとに消費電力を測定して、20秒ごとに現在の洗濯機の動作モードを判定します。
もしもうまくいかない場合、洗濯機が電源OFF状態でwatt_averageが0W付近からどんどんドリフトしていないか確認してみてください。
ドリフトする場合はconst double CURRENT_OFFSET = 2350.0の値を調整します。
OLED液晶があると現在の測定値が出せるので調整しやすいですがシリアル通信でもなんとか出来なくはないです。
しばらく運用してみたけど、しっかり洗濯中or脱水中or乾燥中の判定できてる😊 pic.twitter.com/Om7mQ8pJwJ
— わんこ (@wanko_in_lunch) November 19, 2023
まとめ
ドラム式洗濯機の洗濯完了、乾燥完了をAlexaに教えてもらうシステムを構築しました。
仮想接触センサが作れれば何でもAlexaの定型アクションを叩けるので、もっとおもしろい使い方もあると思います。


