2026-08-22: Shots that upload themselves
It’s been about a week since the last entry, and the big outstanding item from it is done - shots now upload themselves. Beyond that it’s been a fairly control-heavy week: the pump got finer resolution, brew steps learned about limits, schedules became something you can set from the machine itself, and the shot log went through four format versions, which I promise is less alarming than it sounds.
Before we jump in to the nitty gritty, I’ve shot a short video showing some of the updated UI.
Automatic shot upload
Shots now upload themselves the moment they’re stored, which was the one big open item from the last entry. Pull a shot, and by the time you’ve knocked the puck out it’s already on Plantlet without you having had to do anything about it.
Getting there didn’t go the way I expected. I built it over verified TLS first, on the entirely reasonable-sounding grounds that pushing shot logs across the internet ought to be encrypted the same way everything else is these days. TLS lost that argument, and it lost it on memory: MbedTLS wants something in the region of six kilobytes of heap just to get through a handshake, and the comms processor had 424 bytes going spare. There isn’t a clever way around a gap that size.
So uploads are Noise over plain HTTP instead. That costs roughly 600 bytes of heap rather than six thousand, and it saved 148 kB of flash into the bargain, which is not nothing on a part where flash is a genuine constraint. Upload is configurable from the machine’s web interface, and there’s an off switch.
Plantlet grows accounts
Uploading automatically needs somewhere to upload to, which meant Plantlet had to learn who owns what. It has users, machines and shot ownership now - a fairly boring set of features to build, but a prerequisite for most of the interesting ones. In keeping with it being self-hostable, the actual endpoint you upload to is configurable.
Shots can be deleted now, and annotations edited after the fact. The editing goes slightly deeper than you might expect: an edit rewrites the stored log rather than just the index row, so the file on disk and the thing you’re looking at in the browser can’t quietly drift apart from one another.
Shots an agent can read
Plantlet exposes the shot log over MCP, so an agent can list shots, read one, and pull the curves out of it.
Writing those tools turned out to be more interesting than I’d assumed it would be, because the reader on the other end can’t look at a chart, and that single constraint ended up deciding most of the design. A person looks at a shot by, well, looking at it - the shape of the pressure curve tells you most of what you want to know in about a second, and you don’t have to think about how. Everything an agent gets has to survive being said in numbers instead.
I’m also looking in to adding a routine editor to Plantlet, and an MCP tool to create routines.
Buttons, menus and routines
The menu system for the GS3’s front panel now matches the button layout the original machine has. It’s a lot more intuitive when the button’s actually do what they’ve always done and match what’s printed on them.
Chord recognition had to be rewritten. Every chord was quietly degrading to whichever button happened to be released last, so pressing two buttons together did whatever the second one of them did on its own. That’s the kind of bug that’s obvious in hindsight and nearly invisible in use, because the machine does something in response - just not the thing you asked for. Fixing it meant debouncing had to follow.
Menus and input have moved into two shared crates, both of them host-testable, and the Silvia moved onto the same model. That turned up several things the Silvia had been hiding, including routine selection killing the input task outright. There’s still a lot to be done with regards to de-duplication between the GS3 and Silvia firmwares - it’s important for adding other firmwares as well, but I’m a big fan of the old adage about eating elephants.
Routine format v5 (which technically is v2, but get’s the moniker 5 for technical reasons) brings versioning, declared sensor prerequisites, linked parameters, and exit conditions on the extraction sensors. The prerequisites are the practically useful one of those: a routine that needs a scale you haven’t got is greyed out with the reason attached, rather than starting cheerfully and then failing somewhere in the middle of a shot.
The panel also says what the machine is doing during water dispensing or (electronically controlled) steaming.
Schedules you can set from the machine
Schedules already existed, and had for a while, but they were bug-ridden. They kinda-sorta ran, they had their own flash range, and the only way to touch one was from a browser - the panel would show you when the next one was due and then offer you no way whatsoever to change it. The bugs are fixed, there’s a Schedules menu now, with an editable time and an on/off switch. Authoring recurrence stays in the browser, which I think is the right split rather than a cop-out: setting a time is a two-field edit that a couple of buttons or a rotary encoder handles perfectly well, and describing a repeating pattern really isn’t.
Up until now, the machine had been running on UTC. I had written some initial plumbing months ago, but set_timezone had no call sites at all - both firmwares hard-coded a zero offset underneath a commented-out init line - so every schedule fired on UTC and the panel clock showed UTC. The pleasant part is that the scheduler itself needed no changes at all. It was correct code being handed the “wrong” zone the whole time.
Logs stay on UTC, deliberately. Nothing in this change moves a timestamp that has already been written.
Limits
A brew step can now cap one quantity while controlling a different one - hold 9 bar, say, but never exceed 2.5 ml/s. It’s the commonest idiom in the profiles people actually share with each other, and until now the machine had no way of expressing it at all, which was becoming a real gap.
The thing that took me a while to properly internalize is that a limit isn’t a clamp on the pump. It’s a handover of control. Two loops run at once - the one following your setpoint and the one holding the cap - the lower of the two outputs wins, and control authority moves to whichever of them is currently binding. A couple of rather nice properties fall out of doing it that way. Because nothing switches on a threshold, there’s no discrete event for the machine to chatter around. And because the lower output always wins, a limit can never raise the pump, which means one left armed while the pump is off can’t start it.
This does mean that we can’t express a minimum limit, just a maximum, but I can’t really come up with any place where a minimum limit would be a reasonable choice.
What actually took the longest was working out what the two loops owe each other. The loop that isn’t driving has to be held at the output of the one that is, on every single iteration - otherwise it winds up against an error it isn’t controlling, and then takes over with a great step the next time it wins. And a limit loop has to inherit whatever the pump is already doing at the moment it engages, or its very first output is a number with no relationship to the machine at all. For a 1.5 ml/s cap against a group reading zero, that works out at about 15 on a 0-255 scale, against a main loop asking for 120 - so the limit wins by accident and slams the pump shut. I found that one by writing a test to assert the opposite and then watching it fail, which is a rather satisfying way to find things.
Shot logs record what was capping the pump and whether the cap was actually binding at that instant. That isn’t decoration either. Without it a limited shot is indistinguishable from an unlimited one after the fact - and it’s genuinely worse than merely missing, because while a limit is binding, the pump’s PID terms in the log belong to a different quantity than the setpoint sitting next to them names. You’d read a flow loop’s output as though it were a pressure loop’s, and get a perfectly plausible wrong answer out of it.
Finer pump control
The pump PID worked in whole percent, which meant the smallest correction it could possibly make was a full percent of pump output. The PWM slice behind the pump runs at 15000 counts, so that coarseness was entirely self-inflicted rather than anything to do with the hardware. The control path runs on 0-255 now, and the loop has roughly two and a half times the authority it had before. I suppose this means the value is now in perducentiquinquagintaquinque, rather then percent?
Luckily, you won’t have to try to pronounce that word, as percent is still what you author, edit and send over the API, because percent is what a person actually reasons in - nobody wants to think about their preinfusion in 255ths. The split is a boundary between the setpoint and the pump, not a rename.
Knowing which task is stuck
Every task checks in now, and the whole table ships to a host once a second. The reasoning is that a fed watchdog only really proves the executor is running - it tells you nothing at all about whether the task you actually care about has quietly stopped doing its job while everything around it carries on looking perfectly healthy.
For the moment it observes rather than enforces, because a deadline set wrong turns a working board into a reboot loop, and I’d rather have some idea of what normal looks like before anything starts acting on it. The next step is moving watchdog feeding onto it though, because as a monitoring system and for recovering a stuck machine, it is much better.
Shot log v5 through v8
Four format versions in a week, which sounds worse than it is - since I actually use my machine while developing, it’s just easier to tag a new version for even small changes.
v5 adds pump RPM, which the GS3 had been measuring and then throwing away, along with tasting notes. v6 records what the pump was being aimed at, which is the first field in a sample that isn’t a measurement. That turns out to matter rather more than it sounds like it should, because it’s the field that makes a control problem visible at all - a setpoint the measurement never converges on looks like absolutely nothing in the measurements alone.
v7 is the odd one out, and the interesting one. It moves the recorded pump duty onto the pump’s new 0-255 scale, and the bytes don’t change at all, because both of them are a byte. A v6 file read as a v7 therefore decodes perfectly and plots the pump trace at 2.55 times the truth, looking entirely reasonable the whole time it does so. The version number is the only thing separating the two files, which makes it the one kind of format change a round-trip check can’t catch on its own.
v8 records what was capping the pump, and whether the cap was binding.
Plantlet reads all four. It also repairs three instrument artifacts centrally rather than once per consumer, and the extraction sensors finally have units that all three consumers agree on.
Finding a shot in the library
Group rinses, backflushes and heat-ups all get recorded like any other shot, and they’re emphatically not what you’re looking for when you open the library. They’re classified as utility shots now and kept out of the list by default.
Shots can be downloaded as their raw log, too. That route had existed for a while and nothing had ever linked to it.
And the shot header’s lower half was three stranded paragraphs borrowing their margins from whichever CSS reset happened to be in scope - which differs between Plantlet and the firmware’s own frontend, where there isn’t one at all. It’s a single block with every margin stated now.
The machine’s API moved
The machine API is on the WebSocket now, and 25-odd HTTP routes went with it. What had kept it off for so long was a frame size cap, which had already quietly pushed two features onto HTTP rather than anyone raising it. Essentially, the only things left on HTTP are serving the SPA and shot log downloads.
The shot log panel pages, deletes and live-updates over it, and routine changes now push from the repository rather than from one particular write path - so a routine changed by any route shows up everywhere, instead of only showing up when it’s changed by the path that remembered to send a notification.
Plantlet also has a basic mobile layout, so if you want to check out the latest shot log from the breakfast table, you should be good to go.