Displaying My Washing Machine's Remaining Time With curl, jq, and pizauth

Blog archive

Recent posts
Some Reflections on Writing Unix Daemons
Faster Shell Startup With Shell Switching
Choosing What To Read
Debugging A Failing Hotkey
How Often Should We Sharpen Our Tools?
Four Kinds of Optimisation
Minor Advances in Knowledge Are Still a Worthwhile Goal
How Hard is it to Adapt a Memory Allocator to CHERI?
"Programming" and "Programmers" Mean Different Things to Different People
pizauth: First Stable Release

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.

Newer 2023-04-11 10:15 Older
If you’d like updates on new blog posts: follow me on Mastodon or Twitter; or subscribe to the RSS feed; or subscribe to email updates:

Footnotes

[1]

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.

[2]

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.

Comments



(optional)
(used only to verify your comment: it is not displayed)