Skip to content

The coffee log, rebuilt in .NET

Calculating...

The coffee log, rebuilt in .NET

Back in June I wrote about my coffee machine writing my blog. Smart plug watches the coffee machine, Raspberry Pi watches the plug, bit of Python tells Umbraco I've made a coffee. If you've not read that one, go and read it first, it covers the plug, the KLAP handshake, the controller on the site, all of it. None of that has changed.

So why am I writing about it again? Because I've ripped out the middle bit and rewritten it in .NET. And I know, I know, "it worked, why touch it?" Fair question. Let me explain.

It worked. I just couldn't remember how.

Here's the thing. I write C# all day. I don't write Python all day. So when I came back to the coffee log after a few months, I opened the script and had to learn it all over again. Which virtual env was it? Why is that library pinned to that version? Why is that one line commented out?!

The Python version was written for a Pi Zero, which meant pure Python only and being very careful about what got installed. All sensible at the time. But six months on it just felt like someone else's project.

The Pi has since been upgraded to a 64 bit model, and that opened a door I couldn't resist. .NET can publish self contained. One binary, no runtime to install, no pip, no package manager, nothing. Copy it over, run it. That alone was enough for me to fire up a new solution.

What a month of data taught me

In June the state machine had two ideas. Power goes up and stays up for a few seconds, that's a brew starting. Power drops and stays down for a while, that's a brew finished. Simple, and it mostly worked, because my machine switches itself off after brewing so every on, brew, off is one clean session.

Mostly. Every so often it would log a coffee I hadn't made, and I couldn't tell you why, because all I had was the post on the site and a vague memory of what I'd been doing that morning.

This time round the service writes every brew it detects to a JSON file on the Pi as well as posting it, and honestly that file turned out to be the best thing in the whole project. A month of it and I could see exactly what was going on. Have you ever looked at a month of your own coffee habits as JSON? No? Just me then.

So the detector now has a few more rules. It needs a run of readings above the threshold before it'll commit to a brew, a run below before it'll call one finished, a minimum duration so the short blips get binned, and a cooldown afterwards. That last one is the guard rail I care about most. If I make two or three cups in quick succession, say when we've got people round, it counts as one. I'm not after 100% accuracy here, it's a blog post about coffee, not a billing system. I'd much rather miss the odd cup than post about ones I never made.

Replay mode, or how to tune a coffee machine without drinking 40 coffees

The service logs every raw reading to a CSV, one file per day. And it has a replay mode. Point it at a CSV instead of the plug and it runs the whole day through the detector in about a second, using the timestamps from the file rather than the clock on the wall.

The trick is that the detector doesn't know or care where its readings come from. There's one interface for "give me a power reading", and either the real plug or a CSV file sits behind it. Everything downstream is identical.

flowchart LR plug[Tapo plug] --> meter csv[Yesterday's CSV] --> meter meter{{IPowerMeter}} --> detector[Detector] detector --> json[JSON file] detector --> site[owain.codes]

Swap the box on the left and nothing else changes. That's the whole reason it's tunable.

Nothing gets written in replay mode. No CSV, no state, no posts to the site, it doesn't even need credentials. So I can copy a month of captures off the Pi, replay them on the desktop with different settings, and check the results against the times I know I made a coffee. Change a number, run it, a second later I know if it helped. No standing at the machine pulling shots I don't want. No risk of publishing a post about a coffee from August either, which, trust me, I thought about.

Why I stopped retrying

Where a brew goes is now pluggable. There's a JSON file sink, an Umbraco sink, and a mode that does both, and I run both.

In June the Pi had a little pending queue that kept retrying a post until the site answered. Sensible, right? Until you remember the controller creates a node every single time it's called, and there's nothing in the request that lets the site spot a repeat. So if the site processed the request but the response got lost on the way back, the retry publishes the same coffee twice. Two blog posts, one coffee. Nobody needs that.

So the .NET sink deliberately never retries. If a post fails it gets logged and the brew's still sitting in the JSON file on the Pi. A missing post I can fix by hand in a minute. A duplicate one I have to notice first, and I won't.

The afternoon I lost to two keys

Right, confession time. Wiring the Pi back up to the site after a few months away, I went into the backoffice and created an API user. Because that's how you talk to Umbraco from outside, right? Management API, client id, secret, OAuth, all of that.

Not here it isn't. The controller from the June post isn't the Management API at all, it's a plain controller sat inside the site, and the only thing guarding it is one shared secret in an X-Api-Key header. No OAuth, no client id, nothing to create in the backoffice. The same string just has to live in two places, an environment variable on the Live environment in Umbraco Cloud, and the env file on the Pi. That's it. I'd built the thing and forgotten how it worked. Again.

And then, because one mistake is never enough, I put the secret in the wrong setting. The site also needs to know which node to create posts under, and that's a GUID in a setting called BlogParentKey, which sits right next to the API key in the Cloud portal. So for a good while I had a password sat in a field that wanted a GUID and was staring at 401s wondering what on earth was going on.

Deploying is one command now

The service runs under systemd as its own service account, credentials in a separate env file, and the unit file locks it right down. It can only write its own data folder, it can't see home directories, and it never gives up restarting after a power cut, which on a Pi is a matter of when, not if.

Deploying is a single script. Build for arm64, copy the binary over SSH, stop the service, install, start, print the status. It only ever replaces the binary and the default settings. It never touches the env file, the captured data or the detector's saved state, so I can run it at three in the afternoon and nothing gets lost.

So what's actually different?

Same plug. Same site. Same controller. Same daft idea. What's changed is that I can open this project after six months away, run the tests, replay a day of data to see what the detector thinks, and deploy with one command. It went from a thing I built to a thing I can actually maintain. Which, if you read my last post, is sort of the point I've been circling round for a while now.

Right then. Coffee. You'll know when I have.