A couple of months ago our washing machine produced a considerable quantity of smoke when I opened its door, which I interpreted as a suggestion that a replacement was needed. After rummaging around the internet for advice, a couple of days later a new Miele washing machine took its place in our home.
As well as boggling my mind at how much better the new machine was than the cheap machine it replaced, I was pleased to discover that Miele make available a third party API which one can use to interact with the machine. Putting aside any worries about connecting a high-speed rotating device to the internet, I decided to have a poke around. Although the API is more limited in practise than in theory – the API has support for setting values, but those mostly aren’t used – I eventually realised it can help me solve one recurring problem.
Like most washing machines, ours beeps to notify me that it’s finished. However, I can’t always empty it straight away, the beep is obnoxious and repetitive, and it ends up disturbing everyone in the house. I can turn off the beep, but then I don’t know when the machine has finished. Miele’s app can notify me, but it regularly logs me out, and finding out the time remaining is a chore. What I really wanted is a countdown of the remaining time and notification on my desktop computer. Fortunately, I can do exactly what I want on my desktop computer using basic Unix tools. Here’s what a sped-up version of the result looks like (the middle part is hugely sped up; the end part is sped up slightly less so):
In this post I’m going to explain how I got this working with the Unix shell, curl, jq, and pizauth. Interested readers should not have much difficulty adapting these techniques for other similar situations.
Registration and Authentication
To get started, I first had to register my washing machine to my email address with Miele’s app. It’s not the smoothest experience: I had to give it a couple of tries before it worked, but at least it’s a one-off task.
With my washing machine registered to my email address, I then needed to set up OAuth2 so that I can use the API from my computer. First, I needed an OAuth2 client ID and client secret [1]. Miele allows anyone to generate their own client ID and client secret by registering ourselves as a developer with Miele which means giving them a random “app name” and the same email address used to register the device in the app. I then had to register that app as one I’m allowing to use on my Miele account.
I then needed an OAuth2 tool: I used pizauth because, well, I wrote it. My
~/.config/pizauth.conf
file contains Miele’s authorisation and
token URIs and the client ID and client secret [2] I got by registering myself with Miele:
account "miele" { auth_uri = "https://api.mcs3.miele.com/thirdparty/login/"; token_uri = "https://api.mcs3.miele.com/thirdparty/token/"; client_id = "8nka83ka-38ak-38a9-38ah-ah38uaha82hi"; client_secret = "HOaniszumazr978toh789th789aAGa83"; }
I then ran pizauth server
which causes pizauth to listen for
requests for access tokens. The first time that pizauth is asked to display an access
token it will report an error which contains the URL needed to authenticate
yourself with Miele:
$ pizauth show miele ERROR - Access token unavailable until authorised with URL https://api.mcs3.miele.com/thirdparty/login/?access_type=offline&code_challenge=8akyhq28nahikgkanioqa8yHAatho2troanihtHIHI8&code_challenge_method=S256&client_id=8nka83ka-38ak-38a9-38ah-ah38uaha82hi&redirect_uri=http%3A%2F%2Flocalhost%3A8599%2F&response_type=code&state=auhykFAaaBB
Plugging that URL into a web browser, using the same email address and password as I used in Miele’s App, and selecting a country (in my case “Great Britain”) completes authentication, and pizauth now works as expected.
Getting the device ID
The Miele API is a RESTful API which we can query using curl, though we have to send an OAuth2 access token each time we want it to do something for us. Let’s start by asking the API to list all the devices I’ve registered with Miele:
$ curl \ --silent \ --header "Authorization: Bearer $(pizauth show miele)" \ https://api.mcs3.miele.com/v1/devices
The only surprising part of this is the --header
part:
pizauth show miele
displays an access token on stdout
which is incorporated into the HTTP request. A couple of seconds later, curl
prints a vast wodge of JSON to stdout:
{"000173036828":{"ident":{"type":{"key_localized":"Device type","value_raw":1,"value_localized":"Washing machine"},"deviceName":"","protocolVersion":4,"deviceIdentLabel":{"fabNumber":"000173036828","fabIndex":"44","techType":"WEG365","matNumber":"11385920","swids":["3829","20384","28593","23848","29382","28390","23849","23820"]},"xkmIdentLabel":{"techType":"EK057","releaseVersion":"08.20"}},"state":{"ProgramID":{"value_raw":1,"value_localized":"Cottons","key_localized":"Program name"},"status":{"value_raw":5,"value_localized":"In use","key_localized":"status"},"programType":{"value_raw":1,"value_localized":"Own programme","key_localized":"Program type"},"programPhase":{"value_raw":260,"value_localized":"Main wash","key_localized":"Program phase"},"remainingTime":[1,25],"startTime":[0,0],"targetTemperature":[{"value_raw":6000,"value_localized":60.0,"unit":"Celsius"},{"value_raw":-32768,"value_localized":null,"unit":"Celsius"},{"value_raw":-32768,"value_localized":null,"unit":"Celsius"}],"coreTargetTemperature":[{"value_raw":-32768,"value_localized":null,"unit":"Celsius"}],"temperature":[{"value_raw":-32768,"value_localized":null,"unit":"Celsius"},{"value_raw":-32768,"value_localized":null,"unit":"Celsius"},{"value_raw":-32768,"value_localized":null,"unit":"Celsius"}],"coreTemperature":[{"value_raw":-32768,"value_localized":null,"unit":"Celsius"}],"signalInfo":false,"signalFailure":false,"signalDoor":false,"remoteEnable":{"fullRemoteControl":true,"smartGrid":false,"mobileStart":false},"ambientLight":null,"light":null,"elapsedTime":[1,9],"spinningSpeed":{"unit":"rpm","value_raw":1400,"value_localized":"1400","key_localized":"Spin speed"},"dryingStep":{"value_raw":null,"value_localized":"","key_localized":"Drying level"},"ventilationStep":{"value_raw":null,"value_localized":"","key_localized":"Fan level"},"plateStep":[],"ecoFeedback":{"currentWaterConsumption":{"unit":"l","value":6.0},"currentEnergyConsumption":{"unit":"kWh","value":0.7},"waterForecast":0.5,"energyForecast":0.5},"batteryLevel":null}}}
I don’t know about you, but I find large wodges of JSON rather hard to read, which is irritating because in amongst that JSON wodge is the device ID of my washing machine. That ID is required to use later parts of the API so I need to fish it out somehow. Fortunately, jq allows us to easily extract the field in question:
$ curl \ --silent \ --header "Authorization: Bearer $(pizauth show miele)" \ https://api.mcs3.miele.com/v1/devices \ | jq -r '.[] | .ident.deviceIdentLabel.fabNumber' 000173036828
That jq command actually prints out every device ID I’ve registered with Miele. Since I only have a single Miele device it’s fairly obvious that the single ID displayed is for my washing machine, but if you have multiple IDs, how can you tell them apart? The curl command stays the same as before, but now I use jq to fish out the model number and even a human readable name:
$ curl ... \ | jq -r \ '.[] | .ident | (.deviceIdentLabel | (.fabNumber + ": " + .techType)) + " " + .type.value_localized' 000173036828: WEG365 Washing machine
It’s now clear that “000173036828” is the device ID I want going forward.
Getting the remaining time
Now that I have its device ID, I can use a different part of the API to see my washing machine’s current state:
$ curl \ --silent \ --header "Authorization: Bearer $(pizauth show miele)" \ https://api.mcs3.miele.com/v1/devices/000173036828/state
This gives me another huge wodge of JSON of which only the
remainingTime
field is interesting:
$ curl ... \ | jq -r '.remainingTime' [ 0, 56 ]
That means I’ve got 0 hours and 56 minutes left — but that formatting is rather horrible. My first attempt might be:
$ curl ... \ | jq -r '.remainingTime[0] + .remainingTime[1]' 54
but that’s added the two integers in the array together, so that’s not what I want. Instead, I want to convert each integer to a string and then concatenate them:
$ curl ... \ | jq -r \ '(.remainingTime[0] | tostring) + ":" + (.remainingTime[1] | tostring)' 0:54
What happens when the remaining time goes below 10?
$ curl ... \ | jq -r '(.remainingTime[0] | tostring) + ":" + (.remainingTime[1] | tostring)' 0:9
That’s rather confusing! We need to make sure the minutes are always two digits. There is no builtin way of padding numbers in jq, but we can multiply strings by integers so with a bit of thought we can write:
$ curl ... \ | jq -r \ '(.remainingTime[0] | tostring) + ":" + (.remainingTime[1] | tostring | (length | if . >= 2 then "" else "0" * (2 - .) end)) + (.remainingTime[1] | tostring)' 0:09
The API also tells us what phase the washing machine is currently in, which I find interesting, so let’s print that out:
$ curl ... \ | jq -r '.programPhase.value_localized' Rinsing
A Countdown
At this point, I can print out the remaining time for the current load once: what I really want to do is update this so that I have a meaningful countdown. Before we try and go further, let’s bundle what we’ve got into a simple script so that we don’t have to continually enter commands into a terminal:
#! /bin/sh set -e DEVICE_ID=000173036828 get() { curl \ --silent \ --header "Authorization: Bearer $(pizauth show miele)" \ https://api.mcs3.miele.com/v1/devices/${DEVICE_ID}/state \ | jq -r "$1" } case $1 in phase) get .programPhase.value_localized ;; remaining_time) get \ '(.remainingTime[0] | tostring) + ":" + (.remainingTime[1] | tostring | (length | if . >= 2 then "" else "0" * (2 - .) end)) + (.remainingTime[1] | tostring)' ;; esac
Let’s call that script washing_machine
:
$ washing_machine remaining_time 0:42 $ washing_machine phase Rinsing
What we now want to do is create a loop which prints out the remaining time. A first cut, which updates the remaining time every 30 seconds is as follows:
while [ true ]; do printf "\r%s (%s)" \ $(washing_machine remaining_time) \ $(washing_machine phase) sleep 30 done
That works surprisingly well, but has two flaws. First, when the load is finished,
the loop doesn’t terminate. Second, \r
is “carriage return” which
means that the countdown keeps displaying text on the same line. This works well
provided any new text is the same, or longer, length than what came before.
However, if the new text is shorter we end up with odd output such as:
0:32 (Rinsing)h)
We can fix the first problem by noticing that the load is complete when
remaining_time
returns “0:00”:
while [ true ]; do t=$(washing_machine remaining_time) if [[ $t == "0:00" ]]; then break fi printf "\r%s (%s)" \ "$t" \ "$(washing_machine phase)" sleep 30 done
We can fix the second problem in various ways, but the most portable I know of
uses tput el
after the carriage return. This causes all text between
the cursor (which \r
has moved to the start of the line) and the end of the line
to be cleared:
while [ true ]; do t=$(washing_machine remaining_time) if [[ $t == "0:00" ]]; then break fi printf "\r%s%s (%s)" \ $(tput el) \ "$t" \ "$(washing_machine phase)" sleep 30 done
At this point, I’m into nitpicking mode: it irritates me that my countdown has
a blinking cursor next to it. I can turn that off and on with tput civis
and tput cnorm
respectively. However, I need to make
sure that if my program is terminated early (e.g. because I press Ctrl-C) that
cursor blinking is restored. Fortunately I can use the trap
command to restore blinking if this happens, so I can adjust my program
as follows:
tput civis trap "tput cnorm" 1 2 3 13 15 while [ true ]; do ... done tput cnorm
Last, but not least, when the load has finished I use notify-send
to pop a message up in my desktop telling me the load is complete:
notify-send -t 60000 "Washing finished"
Now that I’ve got that sorted out, I can put it into my script (keeping the now-recursive calls as-is), which now looks as follows:
#! /bin/sh set -e DEVICE_ID=000173036828 get() { curl \ --silent \ --header "Authorization: Bearer $(pizauth show miele)" \ https://api.mcs3.miele.com/v1/devices/${DEVICE_ID}/state \ | jq -r "$1" } case $1 in countdown) tput civis trap "tput cnorm" 1 2 3 13 15 while [ true ]; do t=$(washing_machine remaining_time) if [[ $t == "0:00" ]]; then break fi printf "\r%s%s (%s)" \ $(tput el) \ "$t" \ "$(washing_machine phase)" sleep 30 done tput cnorm echo notify-send -t 60000 "Washing finished" ;; phase) get .programPhase.value_localized ;; remaining_time) get \ '(.remainingTime[0] | tostring) + ":" + (.remainingTime[1] | tostring | (length | if . >= 2 then "" else "0" * (2 - .) end)) + (.remainingTime[1] | tostring)' ;; esac
And now each time I use my washing machine I need only execute the following at the command line:
$ washing_machine countdown
Summary
Open standards are great, especially when they can be used with simple tools. Nearly everyone has curl installed on their machine already and most people can easily install jq via their favourite package manager. At the time of writing, I think OpenBSD is the only OS which makes pizauth easily installable, but perhaps that will change as time goes on. Still, hopefully the underlying message of this post is clear: we can often do a lot with simple tools!
Update (2023-04-17): A lot of people (including some commenters here) don’t understand why I don’t simply use a stopwatch instead of the script above. There are a few reasons (e.g. a stopwatch is actually more effort for me to run than my script) but one is particularly important: modern washing machines adjust their time as they run (e.g. based on load weight, or problems when spinning), so the time you see when you start a program can be very different from the actual running time. The script above gets the machine’s current time estimate, which naturally takes into account any such adjustments.
Footnotes
The latter doesn’t make much sense in this context, and it’s not required by the OAuth2 standard, but Miele seem to require it.
The latter doesn’t make much sense in this context, and it’s not required by the OAuth2 standard, but Miele seem to require it.
No, these are not my real client CD and client secret, but they do follow the same format as the real thing. I’ll be doing something similar for other IDs in this post too.
No, these are not my real client CD and client secret, but they do follow the same format as the real thing. I’ll be doing something similar for other IDs in this post too.