
I built a scale because I couldn't trust myself to tap a button
8 min read
I drink too little water. I know this the way everyone knows the unflattering things about themselves: clearly, and without it changing my behaviour at all.
The standard fix is a water-tracking app. I tried a few. They all share one design flaw, and it's fatal: the app asks. It does not measure. So on the days I forget to drink, I also forget to log it, because the forgetting is the whole problem. A tool that needs discipline to fix a discipline gap isn't a tool. It's a mirror with extra steps.
I wanted the opposite. A device that measures and never asks. Glass sits on a thing, the thing knows the weight. Glass goes up, gets emptied, comes back down lighter, the difference is the sip. No button, no app, no "please confirm you drank water." If the system depends on me remembering, the system has already lost.
That was supposed to be a weekend. It is now a multi-variant firmware project with Home Assistant integration and an algorithm. This post is the honest version of how that happens, including the parts where I was wrong.
The core trick: a coaster that weighs#
The whole concept rests on one cheap part. An HX711 amplifier reads a 5 kg load cell, and the cell sits under a cork coaster. An ESP32 reads the weight a few times a second, smooths it, and watches for the signature of a sip: weight stable, weight drops by tens of grams, weight stable again at a lower value. A gram of water is a millilitre. The maths is embarrassingly direct.
Everything else in the project is plumbing around that one idea. Worth saying out loud, because the temptation in hardware is to believe the hard part is the hardware. It wasn't. The load cell worked on day two. The remaining months were software, ergonomics, and me arguing with myself about scope.
Three boards, one firmware#
The first build was a breadboard: ESP32 devkit, the HX711, a small OLED, an RGB LED, a buzzer. It worked and it was the size of a sandwich. So it grew variants.
Variant 1 is the classic coaster: a devkit-class ESP32, OLED, external RGB and buzzer, in a 3D-printed shell I model parametrically in FreeCAD so I can change a spreadsheet cell and regenerate the enclosure. Variant 2 is an all-in-one board with a colour TFT soldered on. Smaller, prettier, and the source of a lesson I'll get to. Variant 3 is a postage-stamp ESP32-C6-Zero, 18 × 21 mm, everything external, for people who want the reminder unit off the desk and separate from the glass.
Supporting three boards from one codebase is where most hobby firmware turns into a swamp of #ifdef BOARD_THIS scattered through every file. I went the other way. There is exactly one place that knows which board this is: a feature-flag block in config.h. It turns a variant number into capability flags (has OLED, has TFT, has the fourth button), and every module reads the capabilities, never the variant number. Adding a board means defining its pins and adding a flag block. That's it. The tracker code doesn't know variant 3 exists. It only knows it has no TFT.
That single decision is the reason this project is still maintainable instead of abandoned.
The toolchain I retired#
Early on I built this with arduino-cli and a layout of symlinks feeding a sketch folder. It worked. It was also a quiet tax I paid every week, because I was also running PlatformIO for the dependency management. Now the board definition, the build flags, and the library list had to be kept identical in two places by hand. They drifted. Of course they drifted. Two sources of truth always drift; the only question is when you notice.
I noticed when a build passed on one path and failed on the other for no reason I could see for an hour. So in v2.5.20 I deleted the arduino-cli path entirely. PlatformIO with the pioarduino fork of the ESP32 platform is now the only way the firmware is built. That fork is the one that actually supports the C6/S3/H2 silicon; the upstream package still ships an old Arduino core that doesn't. Everything goes through one thin shell wrapper:
VARIANT=1 scripts/build.sh # compile + OTA-deploy
VARIANT=3 scripts/build.sh flash # USB flashDeploy is: compile, POST /update to the device, then poll /api/info until it reports the new firmware version or times out. The partition scheme is fixed at the two-slot OTA layout so a bad flash falls back instead of bricking. The lesson here isn't about embedded. It's that a second toolchain you keep "just in case" is not insurance. It's a maintenance liability with a friendly face.
Home Assistant, the way a Shelly does it#
I wanted the device to land in Home Assistant the way a commercial sensor does: plug in, it appears, done. No hand-written YAML. That means MQTT auto-discovery. The device announces its own entities under the discovery topic, keyed on its MAC so three variants can sit on the same broker without colliding. State on one topic, a last-will availability topic so HA shows it offline when it actually is, and command topics for the things you'd want to poke from a dashboard: reset the day, snooze, acknowledge, mark medication.
The bar I held myself to: if setting it up in HA takes more than zero manual config, I'm building it wrong. It takes zero.
Where I got it wrong#
Three mistakes, because the useful part of a build log is the part where the author looks bad.
The captive portal asked for everything at once. The first version of the WiFi setup portal had WiFi, MQTT host, MQTT user, MQTT password and OTA password as one form. Five fields between the user and a device that wasn't online yet. That is the classic onboarding error: demanding the whole setup before delivering any value. The fix was to ask only for WiFi. Once the device is on the network it shows its address on the little screen, and you finish MQTT and OTA later, in a real browser, at your own pace.
Then I forgot cache headers and blamed everything else. I added a field to the device's web UI and it wouldn't show up in the browser. I spent half an hour suspecting the template, the build, the flash. It was the browser holding a ten-minute cache because I had never sent a Cache-Control header on the device's dynamic pages. A hard reload would have "fixed" it, but "the user needs to know to hard-reload" is the wrong sentence to ever say. One no-store header on every dynamic page, written once, never thought about again.
The third one was a genuine design error, and it was mine. I had a "restart now" button sitting inside the MQTT settings box, right under the password field. Further down the page was the global "save" button. Two buttons, two separate actions, but the placement told a different story. A button next to input fields reads as "this button applies to these fields." So people typed their MQTT password, clicked the button directly beneath it, and the device rebooted without saving. Edits gone. Nothing in HA. No error anywhere, because nothing technically failed: the button did exactly what it said, just not what its position promised.
To be blunt about it: that wasn't "suboptimal," it was wrong. I designed a trap and then watched people walk into it. The fix was to stop pretending the two actions were separate. The button now says "save and restart" and does both, in order. A control that lives next to inputs has to honour those inputs, or it has no business being there.
The algorithm I'm actually proud of#
A fixed "no sip in 60 minutes" timer is too dumb to be useful. At 8am I want a nudge every half hour. At 5pm, if I've already had a litre and a half, leave me alone. And if it's 6pm and I'm at 800ml, the last two hours should get noticeably more insistent.
So the firmware holds a few time windows per day, each with a target volume. Inside an active window it recomputes, every half minute, roughly this:
remaining_ml = max(0, window.target - consumed_in_window)
remaining_min = max(1, window.end - now)
avg_sip_ml = daily_ml / max(1, sip_count) // 50 ml fallback
sips_needed = ceil(remaining_ml / avg_sip_ml)
ideal_interval = remaining_min / max(1, sips_needed)
interval = max(min_gap_min, ideal_interval)Three things I like about this. First, avg_sip_ml is learned from today's data, not a constant, so the device adapts to how you actually drink; after a handful of sips the estimate settles. Second, catch-up needs no special case. Behind on the target with little time left? ideal_interval just gets short on its own, until the minimum-gap floor catches it. The "I'm behind" behaviour falls out of the maths instead of being a branch someone has to maintain. Third, min_gap_min is a hard floor and it lives in user settings, not in the code. Some people would rather be permanently a bit behind than be nagged every four minutes. That's a values choice, not an engineering one, so it belongs to the user.
Outside a window, or with adaptive mode off, it falls back to the boring static timer. Predictability is itself a feature.
Knowing when to stop#
The roadmap still has entries: more MQTT command coverage, the real TFT UI for variant 2 on a graphics library that actually supports the C6, multi-day history on flash instead of a small ring buffer in NVS. I'll probably do some of them.
But the honest status is that it works, and I drink more water than I did. The goal is met. Every further feature round now has to clear a higher bar than "because I could." The way personal tooling usually dies isn't shipping too little. It's polishing a thing that was already finished and quietly telling yourself the polishing is progress.
If you want to build something like this, the parts that generalise past water:
- Measure, don't ask. Any tool that depends on the user remembering the thing they forgot is solving the wrong problem.
- Keep one source of truth for configuration. A capability-flag layer beats conditionals sprinkled through the code, and a second toolchain kept "for safety" is a liability wearing a helpful disguise.
- A control next to inputs has to honour those inputs, or it's a trap.
- Adaptive behaviour can just be arithmetic.
remaining / (count × average)with a sane floor does almost everything; the skill is in not adding cleverness on top. - Decide when something is done on purpose, instead of letting it drift.
Repo's private. If you want the firmware or the wiring, ask me. Happy to share both.