Washing Machine Completion Monitor

By Daniel Foote on

Like many homes, we have a washing machine and dryer. But ours are a few generations back, and don't have any of the more modern Wifi features that are becoming common.

In addition, they're on the other side of the house, and we often don't hear when they finish. The net result of this is that sometimes we have wet washing left in the machine, and if left for a few days, it needs to be washed again!

So to prevent this, I added a sensor to the washing machine to send us notifications when it's completed, to allow us to empty it promptly to prevent double-washing.

This isn't at all a new or unique idea. A bit of research on the internet led me to Andrew Dupont's writeup. This used an accelerometer to tell when the machine stopped moving, and send a notification when it had determined it was finished.

Theory of operation

Like Andrew's design, I also opted to go with an accelerometer based approach, to save me having to modify the washing machine itself.

In our case, we have the dryer stacked on top of the washer, so the same sensor works for both appliances - we're not fussed which one has finished, but we want to know when both machines are idle. (So we can load the washing into the dryer, but we can't do that until the dryer is also idle, so this works!)

In a nutshell, we keep an eye on the raw accelerometer values, and distill them into a single movement figure. When this figure is above a threshold for long enough, we consider the machine running. Then, when it drops below this threshold for a period of time, it's considered idle again. The timeouts are to allow for short term quiet periods; for example the washing machine will often come to a complete halt for 20-30 seconds during it's cycle to allow the water to settle and then it will start up again; we need to consider this as a continuous operation.

Once the algorithm determines that it's running or idle, it can then send the relevant notifications; in our case via the Pushover service.

As far as the communication goes - I kept the code on the device very simple; just doing basic calculations and reporting data every 10 seconds. The data was then sent via MQTT to a Node-Red server, which then determined from that data if the machine was running or not. The Node-Red server then sent the Pushover notifications as needed.

Hardware

It turns out I had a M5Stack M5StickC-Plus on the shelf; I'd purchased it for another project, but no longer needed it. This contains everything we need for the project; a built in accelerometer, a display, Wifi, and a suitably powerful processor.

It also came with an extra bonus - internally it contains a magnet which means I can just magnet it to the washing machines - no other mounting hardware was required. This was an unexpected bonus!

The device also comes with a Micropython firmware (well, a M5Stack variant of it) which made for quick coding on the device. The code is fairly lightweight and thus didn't need the complexity of coding in C.

Phase 1

Firstly, I had to collect some raw data to work out what readings meant "working" and what readings meant "idle". I felt that the best way to do this was simply to code a very simple program on the device that reported raw accelerometer values via MQTT every second. On my server, I then subscribed to that topic and piped it all to a file for later processing.

On the M5StickC, this is the program that reported the values:

# boot.py
print("Boot setup; Wifi!")

import wifiCfg
wifiCfg.autoConnect(lcdShow=True)

# main.py
from m5stack import *
from m5ui import *
from uiflow import *
import json
from m5mqtt import M5mqtt
import imu

print("Main program startup...")

setScreenColor(0x111111)

# MQTT setup.
m5mqtt = M5mqtt(
    'washing',
    server='xxxx',
    port=1883,
    user='xxxx',
    password='xxxx',
    keepalive=60,
    ssl=False,
    ssl_params=None
)

# Start connection
print("MQTT connection.")
m5mqtt.start()

# IMU init.
print("IMU Init")
imu0 = imu.IMU()

# UI setup.
XL = M5TextBox(50, 5, "0", lcd.FONT_DejaVu40, 0xFFFFFF, rotate=90)
YL = M5TextBox(85, 80, "0", lcd.FONT_DejaVu40, 0xFFFFFF, rotate=90)
ZL = M5TextBox(50, 150, "0", lcd.FONT_DejaVu40, 0xFFFFFF, rotate=90)

def update_data():
    acc = imu0.acceleration
    XL.setText("%.2f" % acc[0])
    YL.setText("%.2f" % acc[1])
    ZL.setText("%.2f" % acc[2])

    m5mqtt.publish('washingmachine/raw', json.dumps(acc))
    print(json.dumps(acc))

while True:
    update_data()
    wait_ms(1000)

And then on my server, I just dumped the values to a file:

# mosquitto_sub -v -h localhost -p 1883 -t 'washingmachine/raw' -u xxxx -P xxxx >> wmr.txt

After a few days, and a few runs of the washing machine, I had some data:

# tail wmr.txt
washingmachine/raw [0.0, -0.181, 1.042]
washingmachine/raw [-0.003, -0.18, 1.052]
washingmachine/raw [0.005, -0.185, 1.036]
washingmachine/raw [0.006, -0.181, 1.033]
washingmachine/raw [0.006, -0.178, 1.039]
washingmachine/raw [0.009, -0.181, 1.031]
washingmachine/raw [0.012, -0.18, 1.043]
washingmachine/raw [0.009, -0.175, 1.031]
washingmachine/raw [0.012, -0.167, 1.037]
washingmachine/raw [0.01, -0.189, 1.042]

Now it's time to come up with the algorithm to work out when the machine is running or not! I happened to have PHP handy for this one on the server in question, so the prototype script was written in PHP. No reason it couldn't have been any other language; this just happened to work for me!

I wasn't as fancy as Andrew with his system and state machine. I just had a basic algorithm. In short, it just worked out the combined threshold figure, and then averaged this over 10 seconds, and I called every 10 seconds a bucket. Then, there was a simple threshold - if it was above the threshold for a few buckets, it was considered "busy", and then when it dropped below the threshold for a few buckets, it then became idle. I made them constants at the top of the script, and reran the script a few times adjusting it until the output looked like I expected it to look.

Here is the script:

<?php

// Raw input line looks like this:
// washingmachine/raw [-0.033, 1.019, 0.083]

// Slurp them into memory. We've got heaps, right?
$lines = file('wmr.txt');

$readings = array();
foreach ($lines as $line) {
	$bits = explode("washingmachine/raw", $line);
	$raw = json_decode($bits[1]);
	$readings[] = $raw;
}

// Now the constants used in the algorithm later on.
const BUCKET_LENGTH = 10;
const MOVEMENT_AVERAGE = 0.12;
const ON_TIME_THRESHOLD = 6; // in bucket lengths
const OFF_TIME_THRESHOLD = -6; // in bucket lengths

// Ignore the first 3600 readings (1 hour) - I was moving
// the sensor into place and got some random readings.
$readings = array_slice($readings, 3600);

// Split the 1 second reading into bucket sizes.
$buckets = array_chunk($readings, BUCKET_LENGTH);

// A function called for each bucket to summarize it.
function processBucket($bucket) {
	$last = null;

	$minL = 10;
	$maxL = -1;
	$sumL = 0.0;
	$countL = 0;

	foreach ($bucket as $reading) {
		if (!is_null($last)) {
			$deltaX = abs($reading[0] - $last[0]);
			$deltaY = abs($reading[1] - $last[1]);
			$deltaZ = abs($reading[2] - $last[2]);

			$force = $deltaX + $deltaY + $deltaZ;

			$minL = min($minL, $force);
			$maxL = max($maxL, $force);
			$sumL += $force;
			$countL += 1;
		}

		$last = $reading;
	}

	// I've used bcmul() to round off the digits,
	// but it is slower than using floating points.
	return array(
		'min' => bcmul($minL, '1.00', 2),
		'max' => bcmul($maxL, '1.00', 2),
		'avg' => bcmul($sumL / $countL, '1.00', 2)
	);
}

// Our kinda-state machine state.
$state = 0;
$stateCounter = 0;
$stateChangeTime = 0;

// Index loosely equals seconds, as the data points are one per second.
$index = 0;

foreach ($buckets as $bucket) {
	$result = processBucket($bucket);

	if ($result['avg'] > MOVEMENT_AVERAGE && $state == 0) {
		// Currently "off", and we're above the movement threshold, increase the counter.
		$stateCounter += 1;
	} else if ($result['avg'] < MOVEMENT_AVERAGE && $state == 1) {
		// Currently "on", and we're below the movement threshold, decrease the counter.
		$stateCounter -= 1;
	}

	if ($state == 0) {
		// Is currently off - should transition to on?
		if ($stateCounter >= ON_TIME_THRESHOLD) {
			$state = 1;
			$stateCounter = 0;

			echo "OFF FOR: ", ($index - $stateChangeTime) * BUCKET_LENGTH, " seconds\n";
			echo "State change -> ON\n";

			$stateChangeTime = $index;
		}
	} else if ($state == 1) {
		// Is currently on - should transition to off?
		if ($stateCounter <= OFF_TIME_THRESHOLD) {
			$state = 0;
			$stateCounter = 0;

			echo "ON FOR: ", ($index - $stateChangeTime) * BUCKET_LENGTH, " seconds\n";
			echo "State change -> OFF\n";

			$stateChangeTime = $index;
		}
	}

	$index += 1;
}

And with my test data, it gave me this result, which matched what I had in my mind. There are still some false positives, but this bucket size gave me reasonable results.

I'm happy with a few false positives as we'll know when we've had it on or off.

# php wmr.php
OFF FOR: 166080 seconds
State change -> ON
ON FOR: 80 seconds
State change -> OFF
OFF FOR: 60 seconds
State change -> ON
ON FOR: 110 seconds
State change -> OFF
OFF FOR: 33370 seconds
State change -> ON
ON FOR: 4070 seconds
State change -> OFF
OFF FOR: 100 seconds
State change -> ON
ON FOR: 1450 seconds
State change -> OFF
OFF FOR: 81730 seconds
State change -> ON
ON FOR: 2900 seconds
State change -> OFF
OFF FOR: 222980 seconds
State change -> ON
ON FOR: 2800 seconds
State change -> OFF
OFF FOR: 8620 seconds
State change -> ON
ON FOR: 310 seconds
State change -> OFF
OFF FOR: 90000 seconds
State change -> ON
ON FOR: 1290 seconds
State change -> OFF
OFF FOR: 2840 seconds
State change -> ON
ON FOR: 4510 seconds
State change -> OFF
OFF FOR: 8450 seconds
State change -> ON
ON FOR: 1470 seconds
State change -> OFF

Phase 2

With an algorithm that I'm happy with, it's time to make something for production use! To save having to fetch and reprogram the sensor all the time, I decided to make the sensor just collate the readings into 10 second buckets and report that value via MQTT.

So on the sensor, the code was changed to:

from m5stack import *
from m5ui import *
from uiflow import *
import json
from machine import Pin, I2C
from m5mqtt import M5mqtt
import imu

print("Main program startup...")

setScreenColor(0x111111)

# MQTT setup.
m5mqtt = M5mqtt(
    'washing',
    server='xxxx',
    port=1883,
    user='xxxx',
    password='xxxx',
    keepalive=60,
    ssl=False,
    ssl_params=None
)

# Start connection
print("MQTT connection.")
m5mqtt.start()

# IMU init.
print("IMU Init")
imu0 = imu.IMU()

# UI setup.
XL = M5TextBox(50, 5, "0", lcd.FONT_DejaVu40, 0xFFFFFF, rotate=90)
YL = M5TextBox(85, 80, "0", lcd.FONT_DejaVu40, 0xFFFFFF, rotate=90)
ZL = M5TextBox(50, 150, "0", lcd.FONT_DejaVu40, 0xFFFFFF, rotate=90)

last = None
bucket = []

def update_data():
    global last
    global bucket

    acc = imu0.acceleration
    XL.setText("%.2f" % acc[0])
    YL.setText("%.2f" % acc[1])
    ZL.setText("%.2f" % acc[2])

    if last is not None:
        deltaX = abs(acc[0] - last[0])
        deltaY = abs(acc[1] - last[1])
        deltaZ = abs(acc[2] - last[2])

        force = deltaX + deltaY + deltaZ

        bucket.append(force)

    if len(bucket) == 10:
        avg = sum(bucket) / len(bucket)
        bucket = []

        print("10s average", avg)
        m5mqtt.publish('washingmachine/10s', "%0.4f" % avg)

    last = acc

    print(json.dumps(acc))

while True:
    update_data()
    wait_ms(1000)

Then, in my node-red instance I set up the following nodes:

The Node-red nodes

The meat of this is in the decision function node. It re-implements what we had in the PHP script, but with a few minor differences. It uses the context available to the function, but that applies only to this function. In my Node-red instance, I've configured a persistent save location for this data which was needed for other things that my Node-red instance does. If you don't have this configured, it'll be lost each time you restart Node-red.

But the code mostly speaks for itself below!

// Parse the value into a float.
var avg = parseFloat(msg.payload);

// Our hard-coded threshold and settings.
var MOVEMENT_AVERAGE = 0.12;
var ON_TIME_THRESHOLD = 6; // in bucket lengths
var OFF_TIME_THRESHOLD = -6; // in bucket lengths

// Reload our context.
var state = context.get('state') || 0;
var stateCounter = context.get('stateCounter') || 0;
var stateChangeTime = context.get('stateChangeTime') || Date.now();

// Determine where we're at.
if (avg > MOVEMENT_AVERAGE && state == 0) {
    stateCounter += 1;
} else if (avg < MOVEMENT_AVERAGE && state == 1) {
    stateCounter -= 1;
}

var newMessage = null;
var now = Date.now();

// node.warn("Now is " + now);

if (state == 0) {
    // Is off - should transition to on?
    if (stateCounter >= ON_TIME_THRESHOLD) {
        state = 1;
        stateCounter = 0;

        var offFor = now - stateChangeTime;
        newMessage = {}
        newMessage.payload = true;
        newMessage.duration = offFor;

        stateChangeTime = now;
    }
} else if (state == 1) {
    // Is on - should transition to off?
    if (stateCounter <= OFF_TIME_THRESHOLD) {
        state = 0;
        stateCounter = 0;

        var onFor = now - stateChangeTime;
        newMessage = {}
        newMessage.payload = false;
        newMessage.duration = onFor;

        stateChangeTime = now;
    }
}

// node.warn("State " + state + " state counter " + stateCounter + " stt " + stateChangeTime);

// Save our context.
context.set('state', state);
context.set('stateCounter', stateCounter);
context.set('stateChangeTime', stateChangeTime);

// Return the new message. If there is no change,
// this will be null causing it not to emit the message.
return newMessage;

And then the tiny node to format this for Pushover. Basically, we ignore if it turns on; we're only interested when it turns off.

var newMessage = null;

if (msg.payload) {
    // Ignore... we won't notify when it starts.
} else {
    var timeLength = Math.floor(msg.duration / 1000);
    newMessage = {};
    newMessage.topic = "Washing machine or Dryer finished after " + timeLength + " seconds";
    newMessage.payload = "Time to empty!";
}

return newMessage;

It then gets routed into a pushover node, using the standard Node-red pushover node. I created an application for Home automation messages, and then added our device keys to the node. (They don't appear in the export below for security reasons!)

For completeness, here is the JSON export from Node-red:

[
    {
        "id": "04569ce94308cf33",
        "type": "tab",
        "label": "Flow 2",
        "disabled": false,
        "info": "",
        "env": []
    },
    {
        "id": "7dc3c88ef8ad68a6",
        "type": "mqtt in",
        "z": "04569ce94308cf33",
        "name": "Washing Machine Raw Input",
        "topic": "washingmachine/10s",
        "qos": "2",
        "datatype": "utf8",
        "broker": "b093c481fe186acb",
        "nl": false,
        "rap": true,
        "rh": 0,
        "inputs": 0,
        "x": 160,
        "y": 60,
        "wires": [
            [
                "00d5be41e5bdc801"
            ]
        ]
    },
    {
        "id": "00d5be41e5bdc801",
        "type": "function",
        "z": "04569ce94308cf33",
        "name": "Determine Washing Machine State",
        "func": "// Parse the value into a float.\nvar avg = parseFloat(msg.payload);\n\n// Our hard-coded threshold and settings.\nvar MOVEMENT_AVERAGE = 0.12;\nvar ON_TIME_THRESHOLD = 6; // in bucket lengths\nvar OFF_TIME_THRESHOLD = -6; // in bucket lengths\n\n// Reload our context.\nvar state = context.get('state') || 0;\nvar stateCounter = context.get('stateCounter') || 0;\nvar stateChangeTime = context.get('stateChangeTime') || Date.now();\n\n// Determine where we're at.\nif (avg > MOVEMENT_AVERAGE && state == 0) {\n    stateCounter += 1;\n} else if (avg < MOVEMENT_AVERAGE && state == 1) {\n    stateCounter -= 1;\n}\n\nvar newMessage = null;\nvar now = Date.now();\n\n// node.warn(\"Now is \" + now);\n\nif (state == 0) {\n    // Is off - should transition to on?\n    if (stateCounter >= ON_TIME_THRESHOLD) {\n        state = 1;\n        stateCounter = 0;\n        \n        var offFor = now - stateChangeTime;\n        newMessage = {}\n        newMessage.payload = true;\n        newMessage.duration = offFor;\n        \n        stateChangeTime = now;\n    }\n} else if (state == 1) {\n    // Is on - should transition to off?\n    if (stateCounter <= OFF_TIME_THRESHOLD) {\n        state = 0;\n        stateCounter = 0;\n        \n        var onFor = now - stateChangeTime;\n        newMessage = {}\n        newMessage.payload = false;\n        newMessage.duration = onFor;\n        \n        stateChangeTime = now;\n    }\n}\n\n// node.warn(\"State \" + state + \" state counter \" + stateCounter + \" stt \" + stateChangeTime);\n\n// Save our context.\ncontext.set('state', state);\ncontext.set('stateCounter', stateCounter);\ncontext.set('stateChangeTime', stateChangeTime);\n\n// Return the new message. If there is no change,\n// this will be null causing it not to emit the message.\nreturn newMessage;",
        "outputs": 1,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 500,
        "y": 60,
        "wires": [
            [
                "56764b0aabc085ab",
                "7ae019ac7f3d83e2"
            ]
        ]
    },
    {
        "id": "56764b0aabc085ab",
        "type": "debug",
        "z": "04569ce94308cf33",
        "name": "",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "targetType": "full",
        "statusVal": "",
        "statusType": "auto",
        "x": 790,
        "y": 60,
        "wires": []
    },
    {
        "id": "2f51cc59ab47e1a5",
        "type": "inject",
        "z": "04569ce94308cf33",
        "name": "Test Active",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "washingmachine/10s",
        "payload": "0.35",
        "payloadType": "str",
        "x": 220,
        "y": 120,
        "wires": [
            [
                "00d5be41e5bdc801"
            ]
        ]
    },
    {
        "id": "623e6c7622f31eb5",
        "type": "inject",
        "z": "04569ce94308cf33",
        "name": "Test Idle",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "washingmachine/10s",
        "payload": "0.05",
        "payloadType": "str",
        "x": 220,
        "y": 160,
        "wires": [
            [
                "00d5be41e5bdc801"
            ]
        ]
    },
    {
        "id": "7ae019ac7f3d83e2",
        "type": "function",
        "z": "04569ce94308cf33",
        "name": "Format for Pushover",
        "func": "var newMessage = null;\n\nif (msg.payload) {\n    // Ignore... we won't notify when it starts.\n} else {\n    var timeLength = Math.floor(msg.duration / 1000);\n    newMessage = {};\n    newMessage.topic = \"Washing machine or Dryer finished after \" + timeLength + \" seconds\";\n    newMessage.payload = \"Time to empty!\";\n}\n\nreturn newMessage;",
        "outputs": 1,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 540,
        "y": 140,
        "wires": [
            [
                "56764b0aabc085ab",
                "dca8df092dea0d17"
            ]
        ]
    },
    {
        "id": "dca8df092dea0d17",
        "type": "pushover",
        "z": "04569ce94308cf33",
        "name": "Send to Pushover",
        "device": "",
        "title": "",
        "priority": 0,
        "sound": "",
        "url": "",
        "url_title": "",
        "html": false,
        "x": 810,
        "y": 140,
        "wires": []
    },
    {
        "id": "b093c481fe186acb",
        "type": "mqtt-broker",
        "name": "FAFF MQTT",
        "broker": "xxxx",
        "port": "1883",
        "clientid": "",
        "autoConnect": true,
        "usetls": false,
        "protocolVersion": "4",
        "keepalive": "60",
        "cleansession": true,
        "birthTopic": "",
        "birthQos": "0",
        "birthPayload": "",
        "birthMsg": {},
        "closeTopic": "",
        "closeQos": "0",
        "closePayload": "",
        "closeMsg": {},
        "willTopic": "",
        "willQos": "0",
        "willPayload": "",
        "willMsg": {},
        "sessionExpiry": ""
    }
]

Results

It works! Now it's just up to the human factor of paying attention to the notifications unloading the machine once it finishes. We'll just have to see how that goes...!

Future improvements

While it works, there is still a bit of a grey area in determining if the machine is working or not. For more accuracy, we'd need to modify the washing machine's wiring, or use something pointed at the control panel to tell if it's done.

But for now, it works!