高齢の親曰く「洗濯機の運転終了メロディー音が小さくて聞き取れない、洗濯が終わったことに気が付かず、いつの間にか洗濯していたこと自体を忘れてしまう」という。それはボケてるんじゃないか?という話もあるが、とりあえずそうではない、という前提で以下の話を進めることにする。一般的に全自動洗濯機の場合は洗濯物を投入して洗剤やら何やらを投入しスタートすると、洗濯、濯ぎ、脱水を適切に行ってピロピロいって終わる、その最後のピロピロ音が聞き取れないということなのだ。
- 高齢ゆえに耳が遠い(可聴周波数が狭くなって高域が聞こえない)
- そもそも運転終了メロディー音の音量が小さい
- 洗濯機を置く場所がリビングから遠く、ドアなどを閉めていると聞こえにくい
理由としては様々なことが考えられるが、高齢者は一般的に高い周波数が聞こえにくくなるのは事実としてある。昔の洗濯機のブザー音は「ビーーーーーーー」となんとも味気ないうるさい音がしていたわけだが、まぁあれだとわかるのかも。今そんな洗濯機は売られていないだろう(二層式は今でもそうなのだろうか)。
ということで洗濯の完了をわからせるために運転終了メロディー音を爆音で鳴らしたいわけなのである。完了通知とかスマート家電化はいろいろなアプローチがあって簡単なのはSwitchBot プラグミニを使用して消費電力をモニターし、洗濯が完了すれば消費電力量が低くなることをトリガーとして洗濯の完了を検知して通知する、みたいなことだ。実際自分の洗濯機で試してみると、複数回通知されてしまった。
この原因は何かというと、洗濯→濯ぎ→脱水という各工程に移るその時に消費電力量がゼロに近いインターバルがあるらしくそれを拾ってしまうのであった。何W以下と何分待機という条件をそれぞれうまいこと調整すればうまくいくかもしれない、洗濯が終了して濯ぎに移る、或いは濯ぎが終了して排水を行い、脱水に移るまでの間隔が3分程度あるようで、その間の消費電力がほぼゼロなのだろう。
それをうまいこと調整したとして、高齢者にはスマホで通知など意味がなくとにかく洗濯機の運転終了メロディー音を爆音で鳴らしたいわけなので、例えばそのトリガーで別の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のアンプ基板モジュール、分圧抵抗などを仕込んで改造する。中身で使ったのはスピーカーだけ。

回路的にはこんな感じになった。
WashingMachineこれでソフトを焼く。
#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 210000 // 3.5分
#define MEASURE_INTERVAL_MS 20000 // 20秒
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.2;
RTC_DATA_ATTR int bootCount = 0;
unsigned long lastRestart = 0;
bool buzzerOn = false;
bool lastSwitchState = HIGH;
bool alarmTriggered = false;
bool rawHist[3] = {false,false,false};
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);
}
// ----- 3回連続判定 -----
bool decideCurrentState(bool raw, bool lastStable){
rawHist[2]=rawHist[1];
rawHist[1]=rawHist[0];
rawHist[0]=raw;
if(rawHist[0] && rawHist[1] && rawHist[2]) return true;
if(!rawHist[0] && !rawHist[1] && !rawHist[2]) 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;
bool rawCurrentNow = (currentA>0.2);
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はネットに転がっているやつで。
/*************************************************
* Public Constants
*************************************************/
#ifndef PITCHES_H
#define PITCHES_H
#define NOTE_B0 31
#define NOTE_C1 33
#define NOTE_CS1 35
#define NOTE_D1 37
#define NOTE_DS1 39
#define NOTE_E1 41
#define NOTE_F1 44
#define NOTE_FS1 46
#define NOTE_G1 49
#define NOTE_GS1 52
#define NOTE_A1 55
#define NOTE_AS1 58
#define NOTE_B1 62
#define NOTE_C2 65
#define NOTE_CS2 69
#define NOTE_D2 73
#define NOTE_DS2 78
#define NOTE_E2 82
#define NOTE_F2 87
#define NOTE_FS2 93
#define NOTE_G2 98
#define NOTE_GS2 104
#define NOTE_A2 110
#define NOTE_AS2 117
#define NOTE_B2 123
#define NOTE_C3 131
#define NOTE_CS3 139
#define NOTE_D3 147
#define NOTE_DS3 156
#define NOTE_E3 165
#define NOTE_F3 175
#define NOTE_FS3 185
#define NOTE_G3 196
#define NOTE_GS3 208
#define NOTE_A3 220
#define NOTE_AS3 233
#define NOTE_B3 247
#define NOTE_C4 262
#define NOTE_CS4 277
#define NOTE_D4 294
#define NOTE_DS4 311
#define NOTE_E4 330
#define NOTE_F4 349
#define NOTE_FS4 370
#define NOTE_G4 392
#define NOTE_GS4 415
#define NOTE_A4 440
#define NOTE_AS4 466
#define NOTE_B4 494
#define NOTE_C5 523
#define NOTE_CS5 554
#define NOTE_D5 587
#define NOTE_DS5 622
#define NOTE_E5 659
#define NOTE_F5 698
#define NOTE_FS5 740
#define NOTE_G5 784
#define NOTE_GS5 831
#define NOTE_A5 880
#define NOTE_AS5 932
#define NOTE_B5 988
#define NOTE_C6 1047
#define NOTE_CS6 1109
#define NOTE_D6 1175
#define NOTE_DS6 1245
#define NOTE_E6 1319
#define NOTE_F6 1397
#define NOTE_FS6 1480
#define NOTE_G6 1568
#define NOTE_GS6 1661
#define NOTE_A6 1760
#define NOTE_AS6 1865
#define NOTE_B6 1976
#define NOTE_C7 2093
#define NOTE_CS7 2217
#define NOTE_D7 2349
#define NOTE_DS7 2489
#define NOTE_E7 2637
#define NOTE_F7 2794
#define NOTE_FS7 2960
#define NOTE_G7 3136
#define NOTE_GS7 3322
#define NOTE_A7 3520
#define NOTE_AS7 3729
#define NOTE_B7 3951
#define NOTE_C8 4186
#define NOTE_CS8 4435
#define NOTE_D8 4699
#define NOTE_DS8 4978
#endifデバッグは難航したが、何とか形になってシリアルポートからはこのようなログを得た(中略)。補正値(ここでは0.7)をテスターの値を見ながら何度か調整。
Calibration start…
=== Calibration Report ===
Samples: 2000
Average midpoint: 2258.363
StdDev: 22.016
Min: 2124 Max: 2379
==========================
rawAvg=2258 I_rms(A)=0.000 currentNow=OFF
rawAvg=2254 I_rms(A)=0.000 currentNow=OFF
rawAvg=2254 I_rms(A)=0.000 currentNow=OFF
rawAvg=2249 I_rms(A)=0.000 currentNow=OFF
rawAvg=2250 I_rms(A)=0.000 currentNow=OFF
rawAvg=2127 I_rms(A)=3.191 currentNow=OFF
rawAvg=2386 I_rms(A)=3.175 currentNow=OFF
rawAvg=2160 I_rms(A)=3.167 currentNow=ON
rawAvg=2313 I_rms(A)=3.196 currentNow=ON
rawAvg=2199 I_rms(A)=3.174 currentNow=ON
rawAvg=2251 I_rms(A)=0.000 currentNow=ON
rawAvg=2253 I_rms(A)=0.000 currentNow=ON
rawAvg=2253 I_rms(A)=0.000 currentNow=OFF
Detected OFF, timer started
rawAvg=2252 I_rms(A)=0.000 currentNow=OFF
rawAvg=2251 I_rms(A)=0.000 currentNow=OFF
rawAvg=2259 I_rms(A)=0.000 currentNow=OFF
rawAvg=2256 I_rms(A)=0.000 currentNow=OFF
rawAvg=2257 I_rms(A)=0.000 currentNow=OFF
rawAvg=2254 I_rms(A)=0.000 currentNow=OFF
rawAvg=2255 I_rms(A)=0.000 currentNow=OFF
rawAvg=2256 I_rms(A)=0.000 currentNow=OFF
rawAvg=2255 I_rms(A)=0.000 currentNow=OFF
rawAvg=2256 I_rms(A)=0.000 currentNow=OFF
Buzzer START (auto after stop)
Buzzer FINISHED
rawAvg=2251 I_rms(A)=0.000 currentNow=OFF 13行目に洗濯が始まるのだが、3回連続判定としているのは確実性を上げるため。20秒インターバルで電流を測定し、3回連続でONならONとし、OFFならOFFとする。ON→OFFを検知したのち、210秒後にBuzzer STARTする。また、任意のタイミングでPUSH SWを押しても音は鳴るし、鳴っている最中に押すと停まるようにしてある。それで音量を調整すればよい。

あとはこれをどうスマートに置くかという問題はあるが、とりあえずこれで目的は達した。メロディについては童謡・唱歌「シャボン玉」の前半(シャボン玉飛ンダ屋根マデ飛ンダ屋根マデ飛ンデコハレテ消エタ、まで)を使用した。洗濯については石鹸を使うからシャボン玉との相性が良いこと、著作権法の規定により、1995年12月31日をもって著作権が失効していることなどが理由。

