My Coffee Machine Writes My Blog Now
Every time I pull a shot of espresso, a new update appears on this site while also posting to social media channels! I don't lift a finger. The machines do it.
I'm honestly not sure whether that's clever or just the signs of someone bored on a sunny afternoon!
Here's how it works: a smart plug watches the coffee machine, a Raspberry Pi watches the plug, and a bit of Python tells Umbraco when I've made a coffee. Three small jobs. Each one found a brand-new way to make me smile, so let me walk you through it. The idea, the code, and the multiple steps that went wrong.
The daft idea
The plug is a Tapo P110, £8.99 from Amazon, pretty simple. The clever bit is that it measures the power flowing through it. And a coffee machine is a wonderfully obvious thing to measure when you drink the amount of coffee I do. When the machine is idle it sips a watt or two, but brewing it pulls well over a kilowatt.
So if something watches the plug, it can know when I'm making coffee. And if it knows that, it can tell my website. Simple.
┌──────────────┐ local API ┌─────────────────────┐ HTTPS + API key ┌─────────────────────┐
│ Tapo P110 │ ◄───poll────── │ Raspberry Pi │ ────POST brew──────► │ Umbraco 17 (.NET10) │
│ (coffee m/c) │ power (W) │ Python service │ │ custom controller │
└──────────────┘ │ "did I make a ☕?" │ │ → create blog post │
└─────────────────────┘ └─────────────────────┘
Read the plug. Decide a coffee happened. Post it to Umbraco, then use Umbraco.Automate to update my socials. What could possibly be hard about that?
Part 1 — Reading the coffee machine
The P110 has a local API, so you don't need TP-Link's cloud — the Pi can talk to it straight over
my network. There are lovely Python libraries for this. I reached for the most popular one, typed
pip install, and within about ninety seconds I'd killed my Raspberry Pi.
The Pi Zero strikes back
My Pi is an ancient Pi Zero. Tiny. armv6l chip, 512MB of RAM, the computing power of a
damp tea towel. I love it, it runs a couple of bots and that's about it. But it turns out that computing power matters enormously here.
The modern Tapo libraries lean on Rust under the hood, and for armv6l there are no prebuilt
packages — so pip tries to compile Rust from source. Compiling Rust on 512MB of RAM eats all
the memory, the Linux out-of-memory killer wakes up, and the whole machine falls over. I watched
my SSH session die mid-install. Twice. (I'm a slow learner.)
Lesson learned: on a Pi Zero, anything that needs to compile is a trap. I needed dependencies that were pure Python, or nothing at all.
So I wrote my own (kind of)
Newer P110 firmware speaks a protocol called KLAP — a little handshake, then everything's AES-encrypted. The friendly old pure-Python library predates KLAP and just throws its hands up.
But KLAP only really needs AES and SHA, and there's a pure-Python crypto library
(pycryptodome) that installs on the Pi Zero with no fuss and no Rust.
With a lot of chat to Claude.AI I got it to roll my own KLAP client , borrowing the exact recipe from the python-kasa project so I didn't get the byte-ordering subtly wrong (more on that joy later).
The handshake is two requests. I send a random seed, the plug sends one back along with a hash proving it knows my credentials, and I hash back to prove I know them too:
def connect(self) -> None:
local_seed = os.urandom(16)
r1 = self._http.post(f"{self._base}/handshake1", data=local_seed, timeout=self._timeout)
remote_seed, server_hash = r1.content[:16], r1.content[16:48]
if server_hash != _sha256(local_seed + remote_seed + self._auth_hash):
raise KlapError("handshake1 hash mismatch — check your Tapo email and password")
payload = _sha256(remote_seed + local_seed + self._auth_hash)
self._http.post(f"{self._base}/handshake2", data=payload, timeout=self._timeout)
self._session = _KlapSession(local_seed, remote_seed, self._auth_hash)
After that, both sides have agreed on an AES key, and reading the power is just a tidy method call. The plug reports power in milliwatts (because of course it does), so I divide by a thousand:
def get_energy(self) -> "tuple[float, float]":
result = self._request("get_energy_usage")
power_w = float(result.get("current_power", 0)) / 1000.0 # mW → W
today_wh = float(result.get("today_energy", 0))
return power_w, today_wh
Two things the plug did just to spite me
First, a flat 403. My very first handshake got a 403 Forbidden. Even a raw curl was
refused. Turns out recent P110 firmware disables the local API by default — and the fix isn't
in code at all. You flip on "Third-Party Compatibility" in the Tapo phone app. One toggle. The
moment I did, the 403 cleared and the client strolled in. (I'd spent an hour staring at my
code. The fix was on my phone the whole time. Lovely.)
Then, "Padding is incorrect." The client worked perfectly… for about three readings, then threw an AES decryption error. KLAP sessions quietly time out, and a stale one returns gibberish. The fix is to treat any failure as "the session's dead" — bin it, re-handshake, try once more:
for _ in range(2):
try:
if self._session is None:
self.connect()
encrypted = self._session.encrypt(body)
resp = self._http.post(f"{self._base}/request",
params={"seq": self._session.seq}, data=encrypted)
resp.raise_for_status()
return json.loads(self._session.decrypt(resp.content))["result"]
except (requests.RequestException, ValueError, KlapError):
self._session = None # session's dead, get a fresh one on the retry
With that, it runs for hours without complaint. Phew.
Part 2 — Working out I actually made a coffee
You'd think this is the easy bit. "Power is high, ergo coffee." It is not the easy bit.
A coffee machine's power trace is bursty. Here's a real five-second-by-five-second slice of one espresso:
1681 W ← initial switch on
1 W ← settles down to idle
1687 W ← grind the beans
153 W ← pump
9 W ← another dip
1705 W
The boiler and pump flick on and off constantly. A naïve "is the power high?" check would fire a dozen times for a single cup, and I'd end up with a dozen blog posts about one coffee. Nobody needs that.
So the heart of this whole thing is a little state machine that squashes one session of
activity down to a single brew. It has four states — IDLE, ARMING, BREWING, COOLING — and
two ideas that make it behave:
- To start a brew, the power has to stay up for a few seconds (so a momentary blip doesn't count).
- To end a brew, the power has to stay down for a good while. A brief dip — like the heater cutting out mid-shot — gets bridged, so one coffee stays one coffee.
elif self.state is State.COOLING:
if power_w >= self.on_w:
self.state = State.BREWING # power's back — it was only a dip
elif now - self._cool_since >= self.off_debounce_s:
self.last_event = BrewEvent(...) # genuinely quiet now → the brew is over
self.state = State.IDLE
return Transition.COMPLETED
The nice thing is it's pure logic, you feed it (watts, time), so I could test the thresholds
on my laptop without so much as plugging the machine in.
To find the right numbers Claude wrote a little script that just prints live wattage as a bar chart, I made a coffee, and read them off: idle ~1W, brewing ~1680W, dips down to ~9W for up to ~16 seconds. So: start at 150W, treat below 70W as quiet, and bridge dips with a 45-second window. My whole bursty two-minute trace now registers as exactly one brew.
(My machine helpfully powers itself off after brewing, which makes life easy — every on-brew-off is one clean session. If yours stays on all day you'd need something cleverer, because the boiler keeps reheating. A problem for future me.)
Part 3 — The bit that ties it together
main.py is deliberately boring, plain, synchronous code — exactly what a single-core Pi Zero
wants. Poll the plug, feed the detector, and when a brew finishes, queue it up to post:
power_w, energy_wh = plug.get_energy()
transition = detector.update(power_w, now)
if transition is Transition.STARTED:
start_energy_wh = energy_wh # remember the energy at the start
elif transition is Transition.COMPLETED:
ev = detector.last_event
ev.energy_wh = max(0.0, energy_wh - start_energy_wh) # how much this coffee cost me
pending.append(ev)
while pending: # send anything waiting
if umbraco.post_brew(pending[0]):
pending.popleft()
else:
break # Umbraco's down — try again next loop
That little pending queue earns its keep: if the website's briefly unreachable, the coffee
waits and re-sends rather than vanishing. The whole lot runs under systemd with Restart=always,
so it shrugs off reboots. And since it only logs errors and brews, a quiet log is a happy log.
Part 4 — Telling Umbraco about it
Right, how does an HTTP request from a Raspberry Pi become a published post on an Umbraco 17 site?
I skipped Umbraco's proper OAuth-based Management API. Overkill for one trusted gadget on my own
network. Instead I added a plain little controller, guarded by a shared secret in an X-Api-Key
header. The Pi POSTs a scrap of JSON to /api/coffee/brew:
[HttpPost("brew")]
public IActionResult Brew(
[FromHeader(Name = "X-Api-Key")] string? apiKey,
[FromBody] CoffeeBrewRequest request)
{
if (!IsAuthorized(apiKey))
return Unauthorized();
var content = _contentService.Create(title, _options.BlogParentKey, "coffeeBlogPost");
content.SetValue("brewedAt", request.StartedAt.UtcDateTime);
content.SetValue("durationSeconds", (int)Math.Round(request.DurationSeconds));
content.SetValue("peakPowerW", (int)Math.Round(request.PeakPowerW));
content.SetValue("energyWh", (int)Math.Round(request.EnergyWh ?? 0));
content.SetValue("bodyText", BuildBody(request));
_contentService.Save(content); // saved, not published — on purpose, see below
return Ok(new { id = content.Key });
}
It builds the post with Umbraco's IContentService, under a node I've configured, using a
document type called coffeeBlogPost.
Letting the code build its own document type
That document type — with its brewedAt, durationSeconds, energyWh and the rest — has to
exist. And I really didn't fancy clicking it together by hand on every environment. So I let the
code create it on first boot, using an Umbraco package migration:
protected override async Task MigrateAsync()
{
if (_contentTypeService.Get("coffeeBlogPost") is not null)
return; // already there — leave it well alone
var contentType = new ContentType(_shortStringHelper, Constants.System.Root)
{
Alias = "coffeeBlogPost", Name = "Coffee Blog Post", Icon = "icon-coffee-cup",
};
await AddPropertyAsync(contentType, "brewedAt", "Brewed At",
Constants.DataTypes.Guids.DatePickerWithTime);
// …the numeric and rich-text properties…
await _contentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
}
And letting Umbraco.Automate publish it
You'll notice the controller saves the post but doesn't publish it. That's deliberate. I leave publishing to Umbraco.Automate, the visual workflow tool — a workflow fires on "content created" and publishes the coffee post for me. Later I can have it shout about a fresh brew on socials too, without touching a line of code.
Back on the Pi, the actual posting is gloriously dull — which is exactly what you want from the last step:
def post_brew(self, ev: BrewEvent) -> bool:
payload = {
"startedAt": _iso(ev.started_at), "endedAt": _iso(ev.ended_at),
"durationSeconds": round(ev.duration_s, 1),
"peakPowerW": round(ev.peak_power_w, 1),
"energyWh": round(ev.energy_wh, 3),
}
resp = requests.post(self._url, json=payload,
headers={"X-Api-Key": self._api_key}, timeout=10)
return resp.ok
The bit nobody warns you about
The architecture was the easy part. The last mile was a procession of small, very human mistakes — and I'm listing them because you'll hit them too, and it's nice to know you're not alone:
- Stale config. The service reads its settings once, when it starts. I'd change the URL or the API key, test it by hand (worked!), and then sit there baffled as the service kept failing — because it was still running with the old values. Change a setting, restart the service. Every time. Write it on your hand.
- The key didn't match. A
401from the live site told me the endpoint, the routing and the HTTPS were all fine — the secret on the Pi just didn't match the one in Umbraco's settings. It has to be identical. Down to the last character. - A 404 that wasn't my fault (sort of). I'd tucked the controller away in a class library, and ASP.NET Core quietly refused to find it. One line to register the assembly and it sprang to life.
localhost, you absolute liar. The Pi can't reach thelocalhostmy site runs on while I'm developing. Obvious written down. Utterly baffling at eleven at night.
So, was it worth it?
I make a coffee. The machine roars away, does its thing, and clicks itself off. Forty-five seconds later the Pi quietly notes:
Brew finished: 152s, peak 1705 W, 27 Wh.
Posted brew (152s, 27 Wh) -> 200
…and a new post appears on this very site, all by itself, telling you exactly how long it took and how much electricity I just spent on a single cup.
Is it useful? Not in the slightest. But it taught a lot things and was a lot of fun. I learnt about the special hell of ARM build wheels, Umbraco package migrations, and the eternal, humbling truth that you forgot to restart the service.
And really, that's the whole point. The best projects are the daft ones — the ones you build just to see if you can, that teach you five things you didn't expect and leave you grinning at a terminal. My espresso habit has a public changelog now. I couldn't be happier about it.
Right. I'm off to make a coffee. You'll know when I have. ☕