洗濯機の運転終了メロディー音を爆音で鳴らしたい
高齢の親曰く「洗濯機の運転終了メロディー音が小さくて聞き取れない、洗濯が終わったことに気が付かず、いつの間にか洗濯していたこと自体を忘れてしまう」という。それはボケてるんじゃないか?という話もあるが、とりあえずそうではない、という前提で以下の話を進めることにする。一般的に全自動洗濯機の場合は洗濯物を投入して洗剤やら何やらを投入しスタートすると、洗濯、濯ぎ、脱水を適切に行ってピロピロいって終わる、その最後のピロピロ音が聞き取れないということなのだ。 高齢ゆえに耳が遠い(可聴周波数が狭くなって高域が聞こえない) そもそも運転終了メロディー音の音量が小さい 洗濯機を置く場所がリビングから遠く、ドアなどを閉めていると聞こえにくい 理由としては様々なことが考えられるが、高齢者は一般的に高い周波数が聞こえにくくなるのは事実としてある。昔の洗濯機のブザー音は「ビーーーーーーー」となんとも味気ないうるさい音がしていたわけだが、まぁあれだとわかるのかも。今そんな洗濯機は売られていないだろう(二層式は今でもそうなのだろうか)。 ということで洗濯の完了をわからせるために運転終了メロディー音を爆音で鳴らしたいわけなのである。完了通知とかスマート家電化はいろいろなアプローチがあって簡単なのはSwitchBot プラグミニを使用して消費電力をモニターし、洗濯が完了すれば消費電力量が低くなることをトリガーとして洗濯の完了を検知して通知する、みたいなことだ。実際自分の洗濯機で試してみると、複数回通知されてしまった。 この原因は何かというと、洗濯→濯ぎ→脱水という各工程に移るその時に消費電力量がゼロに近いインターバルがあるらしくそれを拾ってしまうのであった。何W以下と何分待機という条件をそれぞれうまいこと調整すればうまくいくかもしれない、洗濯が終了して濯ぎに移る、或いは濯ぎが終了して排水を行い、脱水に移るまでの間隔が3-4分程度あるようで、その間の消費電力がほぼゼロなのだろう。 それをうまいこと調整したとして、高齢者にはスマホで通知など意味がなくとにかく洗濯機の運転終了メロディー音を爆音で鳴らしたいわけなので、例えばそのトリガーで別のSwitchBot プラグミニに接続した何か音が鳴るものをONにする、みたいなことを一旦考えたのだが、SwitchBot プラグミニが2個必要で且つAC電源接続と同時に何か音が鳴るもの、が必要である。突き詰めて考えていくと結構煩わしい。他に何があるか。洗濯機側を一切改造しない(※)という制約条件で考えると、 カメラを使って洗濯機の電源のONとか残り時間とかの表示を監視・判断させる 運転終了メロディー音を検知して判断させる 消費電流を検知して判断させる 一番いいのは3つ目である。カメラも不要だしマイクも不要だ。但しAC電流を測定するセンサーが必要である。我が家の洗濯機日立ビートウォッシュ8kgの消費電力はスペック的には255Wであったので、一般的には200-400W程度と考えられる(乾燥機能がないモデルの場合)。乾燥機付きのドラム式の12kgクラスになると1240Wとドライヤー並みになるが、つまりおよそ200W以上を検知できれば良いということになる。 (※)洗濯機側を一切改造しないというのは、だって実家の洗濯機のメーカーが三菱だったかシャープだったかイマイチ思い出せないし、型番に至っては不明だし、詳細スペックが不明なのである。実家において何を使っているわからないのに自分の洗濯機でデバッグしてみる必要があるので、それがそのままうまくいくとは限らないし、そりに合わせたハードウェア的なカスタマイズをしてしまっても意味がないので出来る限りソフトウェアで吸収するべきなのだ。実際のところどうなのか、消費電流を測ってみることにした。 こんな感じで途中にクランプ式の電流計を入れ、この状態で電流を測りながら洗濯してみると、3A前後で変動することがわかった。まぁ、中華製のテスターなので値そのものはあまり信用していない。 洗濯、濯ぎ、脱水中は3A前後を示す。但しやはり、途中途中はゼロに近くなる。それがこれ。 0.09Aを示しているがこれは洗濯から濯ぎに移る給水中、または濯ぎから脱水に移る排水中などの消費電流値である。単純にこの値を正しいと仮定すると0.09A x 100V=9Wは消費しているのだが、3A(300W)から見るとOFFに見えてしまうのだろう。 さて、この測定を何にやらせるかであるが、Wi-FiやBluetoothに対応はしているものの技適がないので使えないESP32C3 SuperMiniがやはり最適か。センサーはSCT013-005 5A 1Vの非侵襲的スプリットコア変流器センサーを使用。これでAC100Vの片側線にクランプして電流を測定してESP32C3 SuperMiniに判定させる。 何か音が鳴るもの、は何が良いか。余っているPC用のスピーカー、ここではELECOMのマルチメディアスピーカ - MS-87SVの片割れを使うこととした(スピーカーと筐体だけ使えればいいので何でもいい)。PAM8403のアンプ基板モジュールを使い、片chだけ使用。電源は余っていたDC5VのACアダプターを使う。 スピーカーに穴あけ加工をしてセンサーのためのジャックと、プッシュスイッチ、ボリュームを取り付ける。あとは中にESP32C3 SuperMiniとPAM8403のアンプ基板モジュール、分圧抵抗などを仕込んで改造する。中身で使ったのはスピーカーだけ。 回路的にはこんな感じになった。画像は実験中で電解コンデンサがあるが、実際は積層セラミックコンデンサに落ち着いた。 これでソフトを焼く。 #include <pitches.h> #include <Arduino.h> #define SPEAKER 10 #define SWITCH_PIN 2 #define CURRENT_PIN 3 #define LED_BUILTIN 8 #define RESTART_INTERVAL (24UL*60UL*60UL*1000UL) #define CALIBRATION_TIME 10000 // 10秒キャリブレーション #define WAIT_TIME 240000 // 4分 #define MEASURE_INTERVAL_MS 20000 // 20秒 #define CURRENT_THRESHOLD 0.1 const float ADC_COUNTS_PER_V = 4095.0f / 3.3f; const float VRMS_1V_COUNTS = ADC_COUNTS_PER_V * 1.0f; const float COUNTS_PER_A_RMS = VRMS_1V_COUNTS / 5.0f; const float CAL_FACTOR = 0.7; const float DEAD_BAND = 0.05; RTC_DATA_ATTR int bootCount = 0; unsigned long lastRestart = 0; bool buzzerOn = false; bool lastSwitchState = HIGH; bool alarmTriggered = false; bool rawHist[4] = {false,false,false,false};//4回連続判定 bool lastCurrentState = false; static const int tempo = 60; static const float wholeNoteDuration = 60000.0 / tempo * 4; int melody[] = { NOTE_G4,NOTE_C5,NOTE_C5,NOTE_D5, NOTE_E5,NOTE_G5,NOTE_G5, NOTE_A5,NOTE_F5,NOTE_C6,NOTE_A5, NOTE_G5,NOTE_A5,NOTE_G5,NOTE_G5 }; int notedurations[] = { 8,8,8,8, 8,8,4, 8,8,8,8, 8,8,8,8 }; int currentNote = 0; unsigned long lastNoteTime = 0; long midpoint = 0; unsigned long lastMeasureTime = 0; bool forceMeasure = true; bool prevBuzzerOn = false; unsigned long offStartTime = 0; // ----- キャリブレーション安全版 ----- long calibrateMidpoint(int samples = 2000) { uint64_t sum = 0; uint64_t sumsq = 0; int minVal = 4095, maxVal = 0; unsigned long start = millis(); int count = 0; while(millis() - start < CALIBRATION_TIME && count < samples) { int v = analogRead(CURRENT_PIN); sum += (uint64_t)v; sumsq += (uint64_t)v * (uint64_t)v; if(v < minVal) minVal = v; if(v > maxVal) maxVal = v; count++; delayMicroseconds(100); } double avg = (double)sum / count; double variance = ((double)sumsq / count) - (avg*avg); double stddev = sqrt(fabs(variance)); Serial.println("=== Calibration Report ==="); Serial.print("Samples: "); Serial.println(count); Serial.print("Average midpoint: "); Serial.println(avg,3); Serial.print("StdDev: "); Serial.println(stddev,3); Serial.print("Min: "); Serial.print(minVal); Serial.print(" Max: "); Serial.println(maxVal); Serial.println("=========================="); return (long)(avg + 0.5); } // ----- 平均値簡易版 ----- long sampleAverageQuick(int samples=500,int pauseUs=200) { unsigned long sum = 0; for(int i=0;i<samples;i++){ int v = analogRead(CURRENT_PIN); sum += (unsigned long)v; if(pauseUs) delayMicroseconds(pauseUs); } return (long)(sum / samples); } // ----- RMS計算 ----- float readCurrentRMS_counts(int samples=500,int pauseUs=200) { unsigned long long sumsq = 0ULL; for(int i=0;i<samples;i++){ long v = analogRead(CURRENT_PIN); long diff = v - midpoint; sumsq += (unsigned long long)(diff*diff); if(pauseUs) delayMicroseconds(pauseUs); } float meanSq = (float)sumsq / (float)samples; return sqrtf(meanSq); } // ----- 4回連続判定 ----- bool decideCurrentState(bool raw, bool lastStable){ rawHist[3]=rawHist[2]; rawHist[2]=rawHist[1]; rawHist[1]=rawHist[0]; rawHist[0]=raw; if(rawHist[0] && rawHist[1] && rawHist[2] && rawHist[3]) return true; if(!rawHist[0] && !rawHist[1] && !rawHist[2] && rawHist[3]) return false; return lastStable; } // ----- メロディ再生 ----- void playMelody(unsigned long now){ if(currentNote >= (int)(sizeof(melody)/sizeof(melody[0]))) return; unsigned long noteDur = (unsigned long)(wholeNoteDuration / notedurations[currentNote]); if(now - lastNoteTime >= noteDur){ lastNoteTime = now; if(melody[currentNote]>0){ ledcWriteTone(SPEAKER, melody[currentNote]); digitalWrite(LED_BUILTIN, LOW); } else { ledcWriteTone(SPEAKER,0); digitalWrite(LED_BUILTIN,HIGH); } currentNote++; if(currentNote >= (int)(sizeof(melody)/sizeof(melody[0]))){ buzzerOn=false; ledcWriteTone(SPEAKER,0); digitalWrite(LED_BUILTIN,HIGH); Serial.println("Buzzer FINISHED"); forceMeasure=true; } } } void setup(){ pinMode(SWITCH_PIN,INPUT_PULLUP); pinMode(CURRENT_PIN,INPUT); ledcAttach(SPEAKER,5000,10); pinMode(LED_BUILTIN,OUTPUT); digitalWrite(LED_BUILTIN,HIGH); Serial.begin(115200); Serial.println("Calibration start..."); midpoint = calibrateMidpoint(); lastMeasureTime=0; forceMeasure=true; } void loop(){ unsigned long now=millis(); if(now-lastRestart >= RESTART_INTERVAL) ESP.restart(); // スイッチ処理 bool switchState = digitalRead(SWITCH_PIN); if(lastSwitchState==HIGH && switchState==LOW){ if(buzzerOn){ buzzerOn=false; ledcWriteTone(SPEAKER,0); digitalWrite(LED_BUILTIN,HIGH); Serial.println("Buzzer STOPPED (switch)"); forceMeasure=true; } else { buzzerOn=true; currentNote=0; lastNoteTime=now; Serial.println("Buzzer START (switch)"); } delay(50); } lastSwitchState = switchState; if(buzzerOn){ playMelody(now); prevBuzzerOn=true; return; } if(prevBuzzerOn && !buzzerOn) forceMeasure=true; prevBuzzerOn=buzzerOn; // 測定処理 if(forceMeasure || (now-lastMeasureTime>=MEASURE_INTERVAL_MS)){ lastMeasureTime=now; forceMeasure=false; long rawAvg = sampleAverageQuick(200,100); float rms_counts = readCurrentRMS_counts(500,200); float currentA = (rms_counts / COUNTS_PER_A_RMS) * CAL_FACTOR; if(currentA < DEAD_BAND) currentA = 0.0; // DEAD_BAND未満の電流は0扱いとする bool rawCurrentNow = (currentA > CURRENT_THRESHOLD); // しきい値超えたらON bool currentNow = decideCurrentState(rawCurrentNow,lastCurrentState); Serial.print("rawAvg="); Serial.print(rawAvg); Serial.print(" I_rms(A)="); Serial.print(currentA,3); Serial.print(" currentNow="); Serial.println(currentNow?"ON":"OFF"); if(lastCurrentState && !currentNow){ offStartTime=now; Serial.println("Detected OFF, timer started"); } if(!currentNow && offStartTime>0 && (now-offStartTime>=WAIT_TIME) && !alarmTriggered){ buzzerOn=true; currentNote=0; lastNoteTime=now; alarmTriggered=true; Serial.println("Buzzer START (auto after stop)"); } if(currentNow){ offStartTime=0; alarmTriggered=false; } lastCurrentState=currentNow; } ledcWriteTone(SPEAKER,0); digitalWrite(LED_BUILTIN,HIGH); } pitches.hはネットに転がっているやつで。 ...