2026-08-13: Wi-Fi, scales, and shots you can share
It’s been a while since the last entry. The project hasn’t been entirely stale, I made a fair bit of progress in November, but after that I had a lot of other things on my radar. It wasn’t fully quiet - I’ve been using the GS3 pretty much daily, and I’m becoming increasingly confident that the hardware is good. I recently picked up working on the project these past few weeks, and there’s been a lot of progress in that time. You can thank a combination of me being on vacation, me sending out a Variegated GS3 board to a collaborator, and Claude Code.
In that vein, I’m also trying something new with these project logs, starting with this one: the first draft is written by Claude, from the actual commits across the repositories, and then I edit it into shape. It’s not that writing them by hand is hard, it’s that when I do the whole thing by hand the updates end up being fewer and further between - and I do think it’s worth having a decent summary here for people to read. If the result reads oddly to you, do say so. The idea is to have a weekly update when there has been progress made.
As for the work done, back in November my main focus was on EV2 GS3 carrier support, pump RPM metering moving to PIO, a heating element interlock, empty tank blocks, performance counters, the Belka Portal integration, and on the comms processor an HTTP server, the ESPHome protocol and a WebSocket link to the frontend - and then things went quiet for most of the winter and spring.
Cleanup in aisle V
A large effort has been made towards code cleanup. During the interceding months, a lot had happened in the embedded Rust ecosystem, and I wanted to keep up with that work rather than to keep sitting on old versions: embassy 0.10, defmt 1.x, heapless 0.9, sequential-storage 8, and on the ESP32-C6 side esp-hal 1.1, esp-radio 0.18 and trouble-host off a git dependency and onto a proper release.
There’s also been a large effort to consolidate everything. The Rust based firmwares actually consist of a fair number of crates, shared across both the different firmwares (for both different espresso machines and the comms coprocessor), as well as debug tooling. Previously, I’d had these across multiple workspaces (and Git repositories), initially set up that way because I was unable to get it working in a single workspace.
While I was at it, the two espresso firmwares stopped being examples. They had been two binaries inside a single package literally called examples, which neither of them is in substance - they’re what runs on my Silvia and my GS3, and the code has said name: "Silvia" and name: "GS3" all along. They’re a crate each now, variegated-silvia-firmware and variegated-gs3-firmware, and the repository root is sorted into firmwares/ for the three crates that build something you flash and crates/ for everything else - which was becoming a fair question to answer, with forty-odd directories sitting there as siblings and nothing in the names to tell them apart.
That one was hiding a bug, too. The build script chose which board configuration to compile against by reading Cargo features, under a comment about getting the name of the binary being built - which a build script can’t do, since it runs once per package rather than once per binary. Asking for both machines therefore compiled both machines’ code against the GS3’s board config, and a plain cargo build built neither. Each firmware states its own board config outright now, and building both in one invocation works for the first time.
The code also emitted a fair number of warnings on compile - that’s now fixed, meaning we can implement a zero-warning policy in the repo, and not only were there no tests, you couldn’t even run tests due to misconfigurations - also fixed. Of course, being an embedded project, host based testing can only go so far, but and advantage of the crate separation is that a lot of crates are still host-testable.
The net effect is that everything is easier to work with, in anything from just compiling it, to making wide-reaching firmware changes.
Do note that not everything is in the variegated-rs repo - a couple of things are friendly forks of other projects, those will hopefully be mainlined at some point, and esphome-device-rs specifically I feel makes more sense as a stand-alone repository.
Debug output you can actually get at
Up until now, if I wanted to know why the firmware had done something, I had to attach a debug probe and read defmt. That’s fine at the workbench, but not much use once the machine is back in the kitchen and misbehaving mid-shot.
There’s now a structured debug stream that comes out over USB on both processors, and over the network on port 9090 when the machine is online. It carries the log messages that were already there (I bridged the existing logging into it rather than rewriting everything), plus typed events for the interesting state changes and a once-a-second snapshot of what the machine and the comms processor think is going on. If the comms processor panics, the backtrace comes out on the same link instead of disappearing.
Practically, this means I can plug a USB cable into the machine and watch it pull a shot, or leave a laptop connected over Wi-Fi and come back to the log afterwards. It also means that if someone else ever runs into a problem with one of these boards, “send me the debug output” is a reasonable thing to ask.
Both processors also have watchdogs now, and the UI shows when the comms processor has gone quiet rather than just displaying stale numbers as though nothing had happened.
Bluetooth scales
The machine talks to an Acaia scale - specifically the older protocol, which is what my pre-USB-C Lunar speaks - it connects, reports its status, tares properly, and its weight feeds a flow estimator so you get output flow rate alongside pump flow.
Tare was more fiddly than expected. An Acaia tare isn’t instant - it takes a few measuring cycles to settle - so if you assume the command worked the moment it’s acknowledged, you happily record the pre-tare reading as though it were real. The fix is to wait until the scale actually reports zero.
Around the driver there’s now the infrastructure to make it useful to someone other than me: BLE discovery scanning that filters by signal strength and recognises supported scales from their advertised services, an association list stored in flash so the machine remembers yours, and a Bluetooth peripherals page in the web interface to manage it.
While I had the protocols open I (or rather Claude) also wrote up documentation for the Acaia Pyxis protocol, Bookoo and Felicita. I own a newer Acaia, so Pyxis support is on the list. I don’t own a Bookoo or a Felicita, though, and I’m not going to ship a driver I have no way of testing - so those two stay documentation for now, unless one lands on my desk.
The Belka Portal
The scale isn’t the only Bluetooth device the machine talks to. I integrated the Belka Portal back in November and never wrote it up, so here goes.
The Portal is a funnel that sits between the group and the cup, with electrodes in the opening. As coffee runs through it, it measures electrical conductivity and temperature, and uses the conductivity to estimate concentration - so instead of pulling a shot and then measuring TDS with a refractometer afterwards, you can watch concentration change over the course of the shot. Belka went with conductivity rather than optical refractometry because refractometry doesn’t cope well with hot liquid moving through an open funnel. It’s a third-party product, not something of mine, and it’s not cheap, but it measures something nothing else in the machine can see.
The firmware connects to it over Bluetooth as an external sensor and reads conductivity and output temperature off it. Combined with the flow the machine already knows about, that gives an extraction rate, and all three go into the shot log - so concentration ends up on the same time axis as pressure, flow and weight.
Wi-Fi changes without a rebuild
Until two weeks ago, getting the machine onto a network meant writing your SSID and password into the source and reflashing the comms firmware. That’s fine when there’s exactly one user and it’s you, which is why it lasted as long as it did.
The machine now supports Improv over Bluetooth, so you can set it up from a phone or a browser. There’s a new crate for the codec and the GATT service, the comms processor advertises during a provisioning window rather than permanently, and the credentials get handed over the link to the application processor, which stores them in flash. The compiled-in pair is gone.
Memory on the ESP32-C6
The C6 is the tight side of this system, and its heap and stack come out of the same pool, so getting it wrong in either direction crashes the machine.
Most of what I did here was stop estimating. The main task’s stack usage is now measured - 94028 bytes of the 97408 it’s given - and that measurement is what let me move 16 kB from the heap to the stack with some confidence. Beyond that: 24 kB reclaimed from the TCP buffers after a stack overflow, the ESPHome entity table moved into .bss, entity IDs resolved from flash instead of the heap, the Wi-Fi buffer pool capped, and the receive queue raised off a default that was throttling the whole network stack because smoltcp could only drain three packets per poll. The web interface is noticeably snappier as a result.
There is a hardware answer to all of this, and I’m genuinely unsure whether I’d take it. If I end up doing another revision of the APEC SoM, the ESP32-C5 is tempting. Mainly because it can have PSRAM, which would make most of this section moot, but also because it does 5 GHz Wi-Fi - no bad thing for a machine sitting in a kitchen, surrounded by everything else competing for 2.4 GHz. The catch is that the C5 Mini module doesn’t come with PSRAM, and it’s physically larger than the C6 Mini besides, on a board where space is already at a premium. Getting the PSRAM would mean putting the bare IC down on the SoM and doing the RF design myself.
That’s the part I’m wary of. I don’t own a VNA, and I’ve no way of doing proper pre-compliance checks for spurious emissions, so anything I designed I couldn’t really verify. Modules exist precisely so that someone with that equipment has done the antenna matching and put the result through a test lab. The more of the RF work I can leave to people who know what they’re doing and can demonstrate that it works, the better.
The 5 GHz support cuts both ways here, too. It’s genuinely useful, but it’s also the harder half of the radio to lay out - everything about a board gets less forgiving as the frequency goes up. It might also require more board layers or more expensive board materials, and as such it may also drive production cost. So a bare C5 isn’t an obvious trade at all, and counting kilobytes may well stay the cheaper option.
Shot logs
This was the big one. Shots are logged to the SD card now, and getting there meant rebuilding the storage stack.
embedded-sdmmc is out; the stack is async all the way down, on sdio from the embassy project and exfat-slim. I’m running forks of both at the moment, since I needed changes that weren’t upstream, but I’d rather not maintain forks indefinitely - the intention is to get that work mainlined if the maintainers want it. The exFAT formatter on top is mine, and it means the machine can format a card itself, so you can put in a blank one and go. The awkward bit is that an SD card in SPI mode holds chip select low for a whole command, which doesn’t play nicely with a display sharing the same bus - so filesystem operations now hold a lease on the bus for their whole duration. That also got rid of a block_on that had been deadlocking core 1.
While I’m on the subject of the SD card, it also surfaced somewhat of a hardware issue. PWMing the EyeSPI display’s backlight causes signal integrity problems with the card, so the firmware doesn’t PWM it any more - the backlight is on or off. I suspect it may be a matter of picking a different PWM frequency and I might get round to trying that at some point, but honestly, not being able to dim the backlight isn’t a deal-breaker for me.
The file format is on version 4. Shots carry your annotations - dose, beans, grind - alongside output temperature, conductivity and extraction rate from the Portal, and now a timestamp inside the file, so a log that gets separated from its filename still knows when it happened.
Having the logs immediately helped me fix an issue I’ve know about with saturation detection: it had never once fired, and Saturation appeared in no logged sample. The check wanted a 3.0 ml/s drop in flow and a 3.0 bar rise in pressure inside a single 400 ms sample, and replayed against two recorded shots the largest values it ever saw were 1.83/1.22 and 2.07/2.99. Saturation is a five second ramp in practice, so the per-sample change stays small no matter how big the overall excursion is. It’s replaced with something that tracks peak flow and trough pressure across the whole excursion instead, which doesn’t care about the sample rate.
There was also a clock problem I’d been ignoring. The application processor was syncing its time from the comms processor’s RC oscillator once a second, while the battery-backed DS3231 was being written to hourly and never read back - so the accurate clock was write-only and the inaccurate one was in charge, and timestamps drifted. The DS3231 now sets the time at boot and re-anchors every minute, and SNTP corrections are tagged so the application processor only adjusts on a genuinely new sync. SNTP also used to give up permanently if DNS failed once, which meant a machine that booted before its router’s resolver did never got a clock at all until it was power cycled. It retries now.
Getting more out of the sensors
Two things I’d like to try, both of which come down to using sensors the machine already has to work out something you’d otherwise need a separate instrument for.
The first is in-cup TDS. Conductivity stands in for concentration, so in theory - given a calibration factor - you should be able to integrate the Portal’s running conductivity against flow and end up with an estimate of the total dissolved solids in the cup, rather than only the shape of the curve. And since the machine can know the dose in advance, it being one of the annotations you set before pulling a shot, the same numbers would give a running extraction yield: how much of the coffee in the basket has actually made it into the cup, updating as the shot goes.
The second is dosing by flow. On a machine with a properly set OPV - the GS3 has one, the Silvia doesn’t - knowing exactly when the puck saturates should in principle let the flow meter tell you how much has gone into the cup, to something like the precision you’d otherwise need an output scale for. That’s a large part of why saturation detection is in the firmware at all, and why I minded that it had never fired.
Both would be estimates rather than measurements, and in both cases the calibration factor is only the first of the problems. Integration compounds errors - a small bias in a reading doesn’t stay small, it accumulates across the whole shot - so this needs the sensors to be accurate and not merely plausible. Timing matters too: the readings have to line up with each other, and a sensor that’s consistently half a second behind will skew the result even if every individual number it produces is correct.
Flow, at least, is a problem I have several angles on. The machine has three separate sources of information about it - gear pump RPM, the flow meter, and output weight from the scale - and none of them is authoritative on its own. Sensor fusion across the three has been on my list for a while, and both ideas above rest on knowing the flow accurately, so that’s a good reason to get to it.
I do have a refractometer, so at some point I’m going to pull a series of shots, measure them properly afterwards, and find out how close any of this gets.
Variegated Plantlet
Alongside the hardware and the firmware there’s now a third piece: Variegated Plantlet, the cloud component of the Variegated ecosystem. It’s where the things that don’t belong on the machine itself are going to live - storage that outlasts an SD card, a screen rather larger than the one on the front panel, and in time machine setup and shot uploads as well.
What it does today is shot logs, which given everything above is the obvious place to start. Drop a file on the page and you get a permanent link that renders it - pressure, flow, weight and temperature charts on a shared cursor, replay, statistics and a table of the routine steps.
Here’s a shot from the GS3, rendered by Plantlet.
It runs on Cloudflare Workers, with D1 and R2 behind it. There are no accounts and no logins - an upload gives you an unguessable link, and at the moment that’s the whole model.
That won’t survive contact with what I actually want, though, which is for the machine to upload its own shots without me carrying a file around. Automatic uploads need somewhere for a shot to belong and someone to own it, so some concept of users is on the agenda. The bit I expect to be awkward is HTTPS: uploading anywhere means the comms processor doing TLS itself, and on a processor where I’ve spent the last fortnight counting kilobytes, that is not a small ask.
Plantlet is open source, like everything else here, and I wanted self-hosting to be a real option rather than a line in a README. The container runs the same Worker bundle under workerd rather than swapping the storage out for something else, so it’s the same runtime and the same code paths, with no adapter layer that only one of the two deployments ever exercises. When automatic uploads do land, the endpoint the machine uploads to will be configurable - so if you’d rather not have a cloud component in the loop at all, you can point your machine at your own instance and never touch mine.
As for mine: it’s on Cloudflare’s free tier at the moment, which is comfortably enough for the number of shots going through it. If usage ever really took off I’d have to look at covering my own costs somehow, but that’s a problem I’d be very happy to have, and it’s a long way off.
I’d also like to put Improv provisioning into Plantlet, so that setting a machine up is one flow in one place - get it on the network and pointed at somewhere to send its shots, rather than one browser tool for the Wi-Fi and a separate web app for everything after.
The Silvia catches up
The GS3 has had most of my attention, and I hadn’t flashed the Silvia in quite a while, so the two firmwares had drifted apart more than I realised. I sat down and wrote out the differences - excluding UI, and excluding anything the Silvia’s carrier board genuinely can’t do - and started working through them.
The comms link was the first thing. It had stopped working entirely at some point during the drift, and it’s now running at 576 kbaud with hardware flow control.
The steam switch was the more interesting one, because it had rotted in two places at once. The switch sends a command to run an internal routine, and the routine it names had stopped being registered on this firmware, so flipping it did nothing except log a “routine not found” that nobody saw. On top of that, even with the routine in place, entering steam mode would have told the machine to stop heating: the steam boiler’s control state defaulted to off, at 93 °C, which is a brew temperature and wouldn’t have made steam anyway. Both halves are fixed, there’s a Steam Temperature screen so the target is actually configurable, and there’s an over-temperature interlock on the element - which is really what makes exposing that setting reasonable.
Also fixed: machine mode, a stopped brew leaving the pump running, PowerSave with no way out, and the rotary button now being able to turn the machine on from the Machine Off screen. There’s still plenty on that list.
What’s next
- Support for Acaia’s newer protocol, since I have a scale that speaks it
- The rest of the single-boiler list
- A public Plantlet instance, and getting shots to it without a laptop in the middle
- Users and Improv provisioning in Plantlet, which is most of what automatic uploads need
- Finding out whether saturation detection and a flow meter really can stand in for an output scale
- Calibrating the Portal’s conductivity against the refractometer, and seeing if in-cup TDS and a running extraction yield fall out of it
- Sensor fusion across pump RPM, the flow meter and the scale, since almost everything above gets better with a more accurate flow figure
As always, the Discord is the best place to ask about any of this.