Project
An AeroPress that presses itself
coffee-bot grinds beans, heats and doses water, blooms, brews, presses the plunger, ejects the puck, and cleans itself — driven by an ESP32-S3 running ESPHome, with a web app for recipes, live monitoring, and brew history. Built in nine strictly-gated phases: no phase starts until the previous one survives its endurance test.

Right now
Phase progress
Fill = steps resolved (done or deliberately dropped). Click a phase to open it.
System architecture
Two-board control split. The Raspberry Pi owns the UX (web app, recipes, history, graphs) and never touches hardware. The ESP32-S3 owns real-time control (state machine, all I/O, safety interlocks) and keeps operating safely if the Pi disappears. Linux can't guarantee real-time timing; deterministic, fail-safe control lives on the microcontroller.
Major decisions the load-bearing ones
UL-listed appliance, not a gutted kettle
Phase 1 heat comes from a stock Tiger PDU-A50U dispenser. Servos press its buttons from outside — zero electrical contact, zero teardown, the appliance keeps its own thermal safety stack. Why →
Stationary chamber, one axis
A 12″ actuator presses straight down into a fixed AeroPress. The rotating two-station plate was rejected: a motor, indexing, and a thrust bearing bought nothing a cleaning cycle can't. Why →
±15 mL accepted for v1
Open-loop timed pours can't beat pump spin-up variance. ~6% water error is imperceptible in an AeroPress; ±5 mL waits for the load-cell closed loop. Why →
No stirring, by design
Bloom phase + pulsed dispense + an offset pour swirl the grounds for free. Software and geometry replace an entire mechanism. Why →
Temp probe dropped for v1
The Tiger holds its own setpoint 24/7; nothing in the dispense path needs an independent reading. The DS18B20 was a nicety, not a control or safety input. Why →
Never 3D-printed in the wet path
FDM layer lines trap bacteria and coffee oils. Stainless, glass, silicone, or the AeroPress's own polypropylene touch water — PETG is structure only. Why →
Build log key dates
- 2026-05-27
Phase 1 pivot: Tiger PDU-A50U hot-water dispenser replaces the gutted-kettle PID design — UL-listed appliance takes over all thermal regulation and mains risk.
- 2026-05-31
Servo interface chosen over optocoupler teardown — fully reversible, warranty intact. Slip-over 10 mm silicone plumbing replaces the push-in plan.
- 2026-06-03
Servos bench-validated: both MG90S units press the Tiger's buttons with torque to spare. Bracket modeling begins in Fusion 360; stock horns with arc-tuned geometry.
- 2026-06-05
Firmware flashed, live on WiFi. Servos calibrated in-place (unlock 0.55 / dispense 0.45). Dead-time dispense model fitted: 32.0 mL/s + 1750 ms. Auto-relock measured ~10 s.
- 2026-06-08
Two v1 calls: DS18B20 temperature monitoring dropped (appliance self-regulates); ±15 mL open-loop tolerance accepted, ±5 mL deferred to a load-cell closed loop.
- 2026-06-09
Endurance harness written (
scripts/endurance_run.sh) and smoke-tested — cycle 1 fired clean over REST. - next session
Threadlock the unlock horn → native-button check → tank-level flow sweep → 50-cycle endurance run (the Phase 1 exit gate).
The discipline
Phase 0 · complete
Planning & sourcing
Lock the design enough to start buying parts, and get the development environment ready. Everything downstream depends on the architecture choices made here — the control split, the stationary layout, and the staged build discipline.
Tasks
-
done
Finalize the scope document and system architecture
Full plan in
coffee-bot-project-scope.md— nine phases, each with an exit criterion, plus the risk register and decisions log. Control split (Pi UX / ESP32 real-time) and stationary mechanical layout locked. -
done
Choose specific Phase 1 components
The big call: a UL-listed Tiger PDU-A50U hot-water dispenser instead of a gutted kettle + SSR + PID, interfaced by servos pressing its buttons (2026-05-27 / 05-31 decisions). ESP32-S3-DevKitC-1 N16R8 as the controller. Full list in Hardware & BOM.
-
done
Set up the dev environment and repo structure
ESPHome 2026.5.2 installed; repo laid out as
firmware/,webapp/,hardware/,docs/; secrets insecrets.yaml(never committed). -
done
Order Phase 1 parts
No long-lead items — everything ships in 1–3 days (McMaster silicone next-day). ~$435 from scratch, ~$325–365 net of already-owned tools.
-
done
Acquire safety equipment
Inline GFCI adapter (15 A), surge protection, multimeter, Class C fire extinguisher within reach for all powered testing.
Wiring
No wiring in this phase — it's all paper and purchase orders. The wiring story starts in Phase 1.
Phase 1 · current focus
Water heating + dispense
A benchtop rig that holds water at an AeroPress-appropriate setpoint and dispenses ~250 mL on command, failing safe under any single-point failure. The most dangerous and most foundational subsystem — which is why it goes first.
Architecture
A stock Tiger PDU-A50U 5 L hot-water dispenser does all the heating — it's UL-listed, holds its setpoint 24/7 by design, and keeps its own thermal protection stack (thermostat, thermal fuse, dry-fire interlock). The ESP32 never touches mains or water. It drives two MG90S micro servos in a printed PETG bracket clipped over the Tiger's button panel; the servos physically press Unlock (the child-lock can't be disabled, and re-arms in ~10 s) and Dispense (held for the calibrated pour duration). The Tiger is unmodified and fully reversible — its own buttons still work.
Pour volume is open-loop timed using a dead-time model: hold = volume / flow + dead_time with flow = 32.0 mL/s and dead-time = 1750 ms (pump spin-up + tube priming). The dead-time term is what keeps small bloom and pulsed pours proportional, not just the 250 mL target. Water exits the spout through slip-over food-grade silicone tubing (10 mm ID over the 10.5 mm OD spout).
Unlock press level
0.65 overshot the contact arc after a reboot — 0.55 lands squarely. Horn screw needs threadlock.
Dispense press level
Held for the full pour; minimal overtravel to avoid a sustained stall.
Flow rate
Two-point fit at 196 °F. Varies with tank level — full→¼ sweep still pending.
Dead time
Pump spin-up + priming lag, net of end-of-pour dribble. Fitted from 15.0 s→421 mL, 8.9 s→226 mL.
Build sequence
-
dropped · v1
1. ESP32 + DS18B20 temperature verification
Dropped 2026-06-08. The Tiger self-regulates its setpoint, so an independent probe was never control or safety — just a monitoring nicety. Removed from firmware (was GPIO4); the bench probe had also failed its OneWire bus after a water dunk. Re-add later as a food-grade in-tank probe if wanted.
-
done
2. Servo bench test
Both MG90S units validated standalone (servo tester, 06-03) with enough torque to depress the Tiger's buttons, then driven from the ESP32's LEDC PWM on a separate 5 V/2 A supply (06-05). The gotcha was the common ground — without tying the servo PSU ground to the ESP32, both servos spun continuously.
Exit: reliable ESP32-driven sweep, no brownout, quiet at rest. ✓
-
done
3. Dispenser baseline verification
Out-of-box function confirmed before any hardware went near it (non-negotiable — a DOA unit caught early is returnable). Auto-lock re-arm window measured at ~10 s, which gives the firmware's 300 ms unlock→dispense gap ~33× margin. Tiger set to 196 °F and left powered 24/7 as designed (~1 kWh/day standby).
-
done
4. Servo bracket design + print
Parametric Fusion 360 model; PETG print clips over the button panel with no fasteners or adhesives. Stock single-arm horns, shaft axis parallel to the panel so the horn sweeps into the button, contact landing near end-of-travel where tip motion is straight in (minimal skate, max torque).
-
in progress
5. Servo press calibration + validation
Servos mounted and calibrated in-place via the web UI test buttons: unlock 0.55, dispense 0.45 — both press reliably. Remaining: threadlock the unlock horn screw (it drifted after a reboot — geometry must not creep during endurance) and confirm the Tiger's native buttons still work with the bracket installed.
-
dropped · v1
6. DS18B20 body mount + offset calibration
Dropped with step 1. If a probe returns later, it should be a food-grade in-tank unit reading true water temperature — no body-offset calibration needed.
-
done
7. Slip-over silicone tube on the spout
Heat-softened 10 mm ID tube installed two-handed against the spring-loaded spout's flex, routed forward-and-down with strain relief near the spout exit. For endurance testing the tube is routed back into the Tiger's fill opening as a recirculation loop, so tank level stays constant across 50 cycles (~12.5 L of water otherwise).
-
in progress
8. Flow-rate characterization
Steady-state flow 32.0 mL/s and 1750 ms dead-time fitted at 196 °F. Remaining: the full→¼ tank sweep, to quantify how much head-pressure drop moves the pour as the tank empties.
-
done · v1
9. Volume calibration
Centered near 250 mL; repeat pours of 261/237/237 mL show an open-loop spread of ~±15 mL, dominated by pump spin-up variance plus slow tank-level drift — not tunable by timing. Accepted for v1 (~6% of the water, imperceptible in an AeroPress). ±5 mL is deferred to the load-cell closed loop (stop the pour at 250 g).
-
in progress
10. Endurance test — the exit gate
scripts/endurance_run.shfires Nbrew_dispensecycles over the REST API with CSV logging, a pre-flight reachability check, consecutive-failure abort, and a clean Ctrl-C that parks the servos. Smoke-tested 2026-06-09 (cycle 1 ok). The full 50-cycle week-long run — watching for servo drift, bracket slip, relock failures, tank-level effects — is what closes Phase 1.
Wiring diagram
The ESP32 side is entirely low-voltage; the only mains-connected component is the stock appliance behind a GFCI.
restore: false keeps boots deterministic. A reset, brownout, or crash can therefore never press a button: the failure mode is “no press,” never “stuck pressing.” on_boot re-asserts rest as belt-and-suspenders, and there's deliberately no auto_detach_time — a detached arm could drift onto a button, and a detach mid-pour would cut the dispense short.Considerations
- Setpoint is manual, held 24/7. 196 °F covers all AeroPress recipes; 176 °F-style recipes wait for a third servo or teardown. Standby ≈ 1 kWh/day (~$5/mo).
- Unlock before every dispense. The child-lock can't be disabled and re-arms in ~10 s. The firmware's 300 ms gap has ~33× margin; the relock does not interrupt an in-progress pour (confirmed).
- Gentle stall only. The horn bottoms out with slight overtravel and a brief stall — a sustained hard stall cooks MG90S gears. Threadlock the horn screws; they back out under press-and-stall cycling.
- Spring-loaded spout. It flexes toward the tank when pushed. Two-handed tube install, route forward-and-down, strain-relieve within 2–3″ so tubing forces never load the spout's internal spring.
- Tank level moves the pour. Pump output drops as the tank empties — part of the measured ±15 mL. Step 8's sweep quantifies it; the load-cell upgrade makes it irrelevant.
- Tube geometry is part of the calibration. Any Phase 4 plumbing change means re-running flow characterization.
Phase 2 · planned
Press mechanism
Drive the AeroPress plunger through a full press stroke with controlled force and position — including pushing the spent puck out the bottom — with hard limit interlocks the firmware cannot ignore.
Architecture
A 12 V linear actuator (~12 in stroke, ~100 lb force) hangs from the top of the frame and couples to the plunger through a printed PETG coupler (fine here — an air gap separates the plunger from the coffee). A BTS7960 (IBT-2) H-bridge gives direction + speed control; top and bottom limit switches feed the ESP32 as interlocks. The AeroPress sits in a rigid printed cradle on an aluminum baseplate that has to shrug off 30–40+ lb of press force without flexing.
Build sequence
-
todo
1. Wire actuator + H-bridge + 12 V/10 A supply
Verify extend/retract under ESP32 control. The supply needs headroom: 5–8 A under full load, and an undersized brick browns out and stalls the actuator — which looks exactly like a logic bug.
-
todo
2. Add limit switches as hard interlocks
Top and bottom switches; firmware refuses to drive past either. The actuator's internal end-stops protect the motor, but external switches give the firmware position truth to reason about.
-
todo
3. Mount AeroPress in cradle, couple the plunger
Verify alignment and zero binding through the full stroke. Small offsets compound into stuck plungers under load.
-
todo
4. Tune press speed and force profile
Slow initial compression, then steady press. Optional: current-sense on the H-bridge to detect end-of-press by load — the press stiffens when the puck compresses.
-
todo
5. Puck ejection
Drive the plunger fully through the chamber, pushing the spent puck out the bottom into a bin.
-
todo
6. Endurance — 50+ press + eject cycles
Water-only AeroPress, full stroke every time, limit switches respected on every cycle.
Wiring diagram tentative — pins finalize at phase start
Direction comes from which PWM pin is driven (RPWM extend / LPWM retract); both enables tie together to GPIO11. Limit switches are inputs with internal pull-ups, switched to ground.
Considerations
- Cradle rigidity is the whole game. 30–40+ lb of press force; any flex shifts the AeroPress and binds the plunger.
- PETG coupler is fine — air gap between plunger top and coffee means it never touches the food path.
- Current sense (optional) enables end-of-press detection by load and nicer press profiling later; a stepper + leadscrew is the eventual upgrade path for true press profiles.
- Park position: on any fault the actuator parks (retracted) — rule 8 of the safety constraints.
Phase 3 · planned
Grinder integration
Dose a repeatable quantity of ground coffee on command. A gutted blade grinder switched by a relay, time-based dosing, beans in a hopper, grounds out a chute. Deliberately the crude v1 answer — see the decisions log.
Build sequence
-
todo
1. Gut the grinder, wire the motor through a relay
Bypass the lid safety interlock (the machine's enclosure becomes the new interlock), route the motor's hot line through a relay or SSR rated for the motor's inrush.
-
todo
2. ESP32 controls grinder run time
A switch entity + scripted run duration, with a hard maximum runtime as a guard.
-
todo
3. Build the time→grams curve
Grind for N seconds, weigh the output, fit the curve. Expect drift with bean type, hopper level, and blade wear.
-
todo
4. Test repeatability across 20 doses
±0.5–1 g is the accepted v1 spread. By-weight dosing (load cell under the chamber) is the upgrade that fixes this properly — same HX711 hardware as the dispense upgrade.
-
todo
5. Design and test the chute
Grounds must fall cleanly without clinging: steep angle (>60°), smooth or metal surface against static cling, removable for cleaning since it gets coffee-oily over time.
Wiring diagram tentative — finalize at phase start
Relay in the hot leg, normally-open: grinder is off on ESP32 reboot, crash, or brownout. Mains never touches a breadboard (safety rule 4).
Considerations
- Grinder motors are electrically filthy. Snubbers/flyback protection, sensor wiring physically separated from motor wiring, separate ground if EMI shows up in Phase 4.
- Retention is real: some grounds always stay behind. Accept it, or purge with a small pre-grind.
- Dry grounds vs the wet-path rule: a printed chute is acceptable (dry contact), but it still oils up — make it removable.
- Burr-quality grind (e.g. motorizing a Baratza Encore) is an explicit non-goal for v1.
Phase 4 · planned
Mechanical integration
Combine heating/dispense, press, and grinder into one frame and run a full brew cycle end to end. Three individually-working subsystems can still fail together — timing interactions, mechanical interference, EMI, vibration. This phase is where the discipline pays off.
Mechanical layout
Stationary AeroPress, one vertical axis. Actuator on top, ~6 in open dispense zone below the retracted plunger (funnel and spray nozzle offset from the plunger axis, grounds chute entering from the side), chamber in a cradle on the baseplate, mug well below. Side-mounted: elevated boiler, cold reservoir, grinder + hopper, electronics, touchscreen. Footprint ≈ 11 × 12 × 27 in.
Side view, approximately to scale. The offset pour doubles as the stirring mechanism — see decisions.
Build sequence
-
todo
1. Build the frame
Aluminum extrusion or sheet metal. Rigidity is checked at the two load points: the actuator mount and the chamber cradle.
-
todo
2. Mount the actuator, verify travel
Plunger must clear the dispense zone fully retracted and reach through the chamber for ejection fully extended.
-
todo
3. Mount boiler (elevated), reservoir, grinder, electronics
Boiler elevation gives gravity assist; electronics go in a vented enclosure away from heat and splash.
-
todo
4. Position dispense funnel and grounds chute
Both offset from the plunger axis, both aimed into the chamber. Alignment is everything — grounds, water, and plunger all have to land in the same 62 mm circle.
-
todo
5. Route all tubing and wiring
P-clips and clean runs for silicone; strain relief and cable channels for wiring; sensor wiring kept away from motor wiring.
-
todo
6. Write the integrated brew state machine
idle → preheating → grinding → dosing_check → dispensing_bloom → blooming → dispensing_main → brewing → pressing → ejecting → idle, every transition guarded by precondition checks (at temp, water present, chamber seated, limits respected). -
todo
7. Dry run each step, then water-only, then a real brew
Escalate only when the previous level is boring.
System wiring map power domains + signals
Considerations
- Bloom timing: dispense ~20–30% of the water, pause 30–45 s, then the rest in pulses. Software-only, tunable per recipe.
- Thermal neighbors: the boiler warms nearby printed parts — PETG near heat, never PLA (softens ~140 °F).
- Vibration loosens everything — grinder and actuator both. Threadlocker, lock washers, periodic inspection.
- EMI from the grinder is the most likely "impossible" bug — plan wiring separation up front.
Phase 5 · planned
Cleaning system
The #1 failure mode of DIY coffee machines is that they get gross and get abandoned. An automatic cold-spray + hot-rinse cycle keeps daily grime handled, so manual cleaning is a weekly task — and the parts that need it pop out by hand.
Target cleaning cycle
Why cold spray? Coffee oils congeal in cold water and wash away; hot rinses can set tannin stains into plastic. The spray needs pressure (50–100 PSI diaphragm/washer pump), not just flow — peristaltics won't dislodge fines.
Build sequence draft — scope finalizes at phase start
-
todo
1. Bench-test pump + hollow-cone nozzle
Verify the diaphragm pump drives the food-grade nozzle with enough force to dislodge clinging fines.
-
todo
2. Solve spray geometry around the plunger
With a stationary chamber the plunger occupies the center — multiple jets around it, or side-mounted nozzles on the splash shroud hitting the walls.
-
todo
3. Waste drain at the chamber position
Rinse water exits the chamber bottom when the mug isn't there — drain to a waste reservoir with a level sensor so it can't silently overflow.
-
todo
4. Integrate hot rinse + cold spray into the state machine
The hot rinse reuses the Phase 1 dispense path; the spray pump gets its own MOSFET channel.
-
todo
5. Residue evaluation across 20 brew+clean cycles
Inspect the wet path; the cycle has to keep up with daily use on its own.
Wiring diagram tentative — finalize at phase start
Low-side MOSFET with a pulldown: pump is off at boot and on any reset (safety rule 7). The float switch is a hard precondition on the cleaning state.
Considerations
- No cycle replaces weekly manual cleaning — funnel, shroud, cradle, and chamber all pop out by hand. The reference DIY build failed because parts were uncleanable, not because it lacked a cycle.
- Optional spray-line solenoid if the pump alone doesn't give crisp start/stop.
- Mug logistics: cleaning happens with no mug present — that's why the waste drain exists at the chamber position.
Phase 6 · planned
Pi web application
A standalone web app for control, monitoring, recipe management, and brew history with graphs. The Pi owns the experience; it never owns brew logic — the ESP32's state machine stays reflashable without touching the app.
Architecture
Features priority order
- Live brew view — real-time temp curve, state, elapsed, progress
- Brew trigger — pick recipe, start, observe
- Recipe CRUD
- Brew detail — post-brew timeline, state transitions annotated
- History — searchable, thumbnail curves, ratings
- Recipe comparison — overlay brews for consistency
- Boiler diagnostics — rolling temp history
- Scheduling — optional wake-up brew
Build sequence
- todo
1.
aioesphomeapiconnectionSubscribe to all ESP32 state, log to SQLite, survive disconnects.
- todo
2. FastAPI skeleton + SQLite schema
recipes,brew_sessions,brew_metrics(brew_id, ts_offset_ms, metric, value)indexed on (brew_id, ts_offset_ms). Raw parameterized SQL, no ORM. - todo
3. Brew trigger + live view (WebSocket)
The first end-to-end loop: set recipe numbers, press start, watch the curve draw itself.
- todo
4. Recipe CRUD
HTMX server-rendered fragments — no build step.
- todo
5. History + detail views with Plotly
Searchable history with thumbnail curves; detail view annotates state transitions on the timeline.
- todo
6. Polish: comparison, diagnostics, scheduling
The A/B-testing layer that makes the brew data fun.
- todo
7. systemd + Caddy deployment
Boots with the Pi, local HTTPS, survives power cuts.
Wiring
No new wiring — this phase is software. The Pi joins over WiFi; the ESP32's I/O is already in place from Phases 1–5.
Phase 7 · planned
Enclosure & polish
Turn the working mechanism into something that looks finished and is safe on a counter. Deliberately last: resist the urge to start here — it comes after the mechanism works.
Tasks
- todo
Design + build the outer enclosure
Printed panels, sheet metal, or both. Must not trap heat around boiler or electronics — ventilation matters. Removable panels, never glued: you will need to get back inside.
- todo
Enclose all mains wiring
Fully inaccessible during normal use; exposed metal bonded to earth.
- todo
Mount the touchscreen, tidy cable runs
Front panel kiosk for the web app; cables into channels with strain relief.
- todo
Status lighting + cosmetic finishing
Anodized parts, consistent fasteners, splash containment and drip management refined. This is where it starts looking like the render.
Wiring
No new circuits — this phase repackages Phase 4's wiring into enclosed, serviceable runs.
Phase 8 · the goal state
Daily use & refinement
Use coffee-bot every day and refine from real experience. The brew history + ratings become a dataset: A/B test dispense profiles, temperatures, and doses. Data-driven coffee is the fun part.
Ongoing work
- future
Use it daily, log issues
The ultimate test was in the success criteria from day one: it lives on the counter and gets used.
- future
Dial in recipes from history data
Overlay brews, compare ratings, converge on house blends.
- future
Watch for slow failures
Seal degradation, boiler scale (descale on a schedule), loosening connections, grounds accumulating in awkward spots.
- future
Upgrade deliberately — only once v1 is rock solid
Queue, in rough order of payoff: load cell + HX711 (closed-loop ±5 mL pours and by-weight dosing), burr grinder (motorized Baratza Encore), stepper + leadscrew press profiling, Home Assistant integration, scheduled wake-up brews.
Wiring
Only what upgrades bring. The load-cell upgrade adds an HX711 board on two GPIOs — it sits under the mug, outside the wet path, which is exactly why it beat a flow meter.
Reference
Decisions log
Every load-bearing call, dated, with the rationale and what it beat. The rejected option matters as much as the chosen one — it's the answer to "why don't we just…"
Dated decisions
±15 mL open-loop tolerance accepted for v1; precision path = load cell
Measured pour-to-pour spread at the 250 mL target is ~±12–17 mL (261/237/237), dominated by pump spin-up variance + tank-level drift — timing tweaks can't beat it. That's ~6% of the water, imperceptible in an AeroPress. ±5 mL comes later from a closed-loop by-weight pour: load cell + HX711 under the mug, stop at 250 g. Immune to flow variance and tank drift, and the same part later enables by-weight coffee dosing.
over: more timing calibration (can't fix variance) · inline flow meter (food-safe sensor in the wet path is harder than a load cell outside it)
DS18B20 temperature monitoring dropped for v1
The UL-listed Tiger holds its own setpoint 24/7; nothing in the dispense path consumes an independent reading. The probe was never control or safety — just a nicety, and the bench unit failed its OneWire bus after a water dunk. Removed from firmware (GPIO4 freed). If re-added, it should be a food-grade in-tank probe reading true water temperature.
Stock servo horns + arc-tuned press geometry
No custom horn. Shaft axis parallel to the panel so the horn sweeps into the button; contact lands near end-of-travel where tip motion is straight in (minimal skate, full torque). Coarse alignment by spline indexing (~18° steps), fine by ESPHome level:. Committed only after bench-proving both servos had the torque.
over: custom-printed horn/pusher — simpler is adequate
Servos pressing buttons externally — not optocouplers inside
Two MG90S in a clip-on PETG bracket press Dispense and Unlock from outside. Eliminates the teardown entirely: no soldering into the appliance, no warranty/water-tightness/clip-breakage risk, fully reversible, native buttons still work. Cost delta +$13 net. Trade: no Temp Set actuation, so the setpoint is configured manually once (196 °F covers AeroPress; 176 °F recipes deferred).
over: optocouplers across the button taps (teardown, irreversible)
Slip-over silicone plumbing (10 mm ID × 13 mm OD, 50A, platinum-cured)
The Tiger's spout bore has an internal notch in hard plastic — a push-in seal is unreliable. Slipping a soft 10 mm ID tube over the smooth 10.5 mm OD exterior sidesteps the notch and preserves full flow area. 50A durometer for conformance; platinum-cured for bio-inertness (FDA 21 CFR 177.2600).
over: 6 mm push-in fit; wet-path standardization on 6 mm deferred to Phase 4
Hot-water dispenser appliance — not a gutted-kettle PID build
A Tiger PDU-A50U (UL-listed, 5 L, four setpoints) replaces the kettle + SSR + PID concept. The appliance brings its own thermostat, thermal fuse, and dry-fire interlock, removing nearly all custom mains-electrical risk and most of the Phase 1 timeline. Trades arbitrary setpoints for fixed ones — 196 °F is well-centered for AeroPress.
over: gutted kettle + SSR + PID (custom mains + thermal safety stack from scratch)
Founding decisions
Control split: Pi for UX, ESP32-S3 for real-time
Linux can't guarantee timing — fine for a UI, dangerous for control loops. The ESP32 fails safe independently if the Pi crashes; each side develops and tests alone.
Stationary chamber + 12 in actuator
One axis of motion instead of two. The rotating dual-position plate isolated press hardware from coffee nicely, but cost a rotation motor, precision indexing, a thrust bearing, and a press-force lockdown. Cleanliness comes from removable parts + a cleaning cycle instead. A short-stroke chamber-drop slide is the escape hatch if cleaning access proves inadequate.
over: rotating two-station plate
No stir mechanism
Bloom phase (20–30% pour, 30–45 s pause), pulsed dispense bursts, and the off-axis pour's swirl replace stirring at zero hardware cost — all tunable per recipe and loggable for A/B tests.
Gravity-fed boiler + NC solenoid valve for dispense
Simpler, silent, instant cutoff, fails closed. (Phase 1's Tiger uses its own internal pump — this decision governs the eventual integrated machine.)
over: pumped dispense
Blade grinder + time-based dosing for v1
Vastly easier than motorizing a burr grinder. Accepts grind inconsistency and ±1 g dose variance; load cell upgrade fixes dosing later.
Manual filter loading
Automating it is disproportionately hard. One quick human step is the accepted compromise.
ESPHome over custom firmware
Built-in PID, interlocks, OTA, native API; YAML iterates faster than C++. Migrate only if genuinely outgrown.
Food-safe materials in the wet path; FDM for structure only
Layer lines trap bacteria and coffee oils. Stainless / glass / silicone / AeroPress polypropylene where water or coffee flows; PETG (never PLA near heat) for brackets; water-contact aluminum must be Type II anodized.
Reference
Hardware & BOM
Phase 1 bill of materials — ~$435 from scratch, ~$325–365 net of already-owned tools. No long-lead items; everything ships in 1–3 days.
Phase 1 BOM
| Item | Notes | Cost |
|---|---|---|
| Tiger PDU-A50U-K water boiler, 5 L | UL-listed · setpoints 208/194/176/158 °F · ~16 brews per refill | $160 |
| Hosyond ESP32-S3-WROOM-1-N16R8, 3-pack | 16 MB flash / 8 MB PSRAM · one primary + two spares | $30 |
| USB-C data cable + 5 V/3 A supply | data-capable cable — not charge-only | $20 |
| MG90S metal-gear servo, 4-pack | 2 in use + 2 spares · metal gears for press-cycle durability | $15 |
| 5 V/2 A servo supply | separate from ESP32 rail · common ground | $10 |
| PETG servo bracket | printed in-house · clips on, no fasteners | — |
| Proto board, breadboard, wire, headers, heat-shrink | 22 AWG hookup assortment, perma-proto | $39 |
| DS18B20 probes ×2 + pull-up + tape + insulation | now spare parts — monitoring dropped for v1 | $26 |
| Silicone tubing, 10 mm ID × 13 mm OD, 3–5 ft | McMaster high-temp “Soft” 50A, platinum-cured, FDA-compliant | $15 |
| Kitchen scale (0.1 g), cylinder, catch vessel | 1 g ≈ 1 mL — the scale beats a graduated cylinder | $35 |
| Inline GFCI (15 A), surge strip, Class C extinguisher | required even with a UL-listed appliance | $85 |
| Total (from scratch) | ~$325–365 net of owned tools + extinguisher | ~$435 |
Deferred to later phases
| Item | Phase |
|---|---|
| 12 V linear actuator (12 in, ~100 lb), BTS7960 H-bridge, 12 V/10 A PSU, limit switches ×2, coupler, cradle, baseplate | 2 |
| Blade grinder, motor relay/SSR, hopper, chute | 3 |
| Frame, funnel (CNC stainless), plumbing standardization | 4 |
| Diaphragm spray pump, hollow-cone nozzle, MOSFET driver, waste reservoir + level sensor | 5 |
| Raspberry Pi + touchscreen | 6 |
| Load cell + HX711 (closed-loop dispense + by-weight dosing) | upgrade |
Material strategy
| Part type | Material / process |
|---|---|
| Food-contact (funnel, fittings) | CNC stainless (PCBWay, Xometry), off-the-shelf |
| Brew chamber | AeroPress polypropylene — off-the-shelf |
| Structural plates | Laser-cut/bent aluminum (SendCutSend) · Type II anodized if water-contact |
| Brackets, cradles, shields, trays | 3D-printed PETG (never PLA near heat — softens ~140 °F) |
| Reservoirs | Glass or food-grade HDPE, off-the-shelf |
| Tubing | Food-grade high-temp silicone, platinum-cured |
| Seals | Food-grade silicone sheet, cut to fit |
Reference
ESP32-S3 pinout
ESP32-S3-WROOM-1-N16R8 on the Hosyond carrier — 16 MB flash, 8 MB octal PSRAM. The LEDC peripheral generates clean 50 Hz servo PWM on any general-purpose GPIO.
In use — Phase 1
| GPIO | Function | Notes |
|---|---|---|
GPIO5 | Servo PWM — Dispense | 50 Hz LEDC → MG90S signal · servo_dispense_pwm |
GPIO6 | Servo PWM — Unlock | 50 Hz LEDC → MG90S signal · servo_unlock_pwm |
GPIO4 | temp monitoring dropped for v1 (2026-06-08) |
Reserved for later phases tentative
| GPIO | Function | Phase |
|---|---|---|
GPIO9 / 10 / 11 | H-bridge: direction ×2 + enable/speed | 2 |
GPIO12 / 13 | Limit switches top / bottom (INPUT_PULLUP) | 2 |
GPIO14 | Grinder relay | 3 |
GPIO15 | Spray pump MOSFET | 5 |
GPIO16, 17, 18, 21, 38–41, 47, 48 | Free / future (waste float, load cell, …) | — |
Restricted — do not use
| Pin(s) | Why |
|---|---|
GPIO0 | Strapping (boot mode) — must be HIGH at boot |
GPIO3 | Strapping (JTAG vs USB) — set by USB hardware |
GPIO19, GPIO20 | Native USB D− / D+ |
GPIO26–32 | Internal flash interface — not exposed |
GPIO33–37 | Octal PSRAM bus (N16R8) — not exposed |
GPIO45, GPIO46 | Strapping pins — avoid |
restore: false; a reboot, brownout, or crash can never press a button. There is no electrical path from the ESP32 to the dispenser at all — a servo arm pressing a plastic button transfers zero volts.Reference
Safety constraints
Mains power plus water demands multiple independent layers of protection. Hardware interlocks back up software — never the reverse. These rules are never violated, in any phase, for any convenience.
| # | Rule |
|---|---|
| 1 | Never disable a hardware interlock in software for convenience |
| 2 | Never rely on the ESP32 as the only thermal protection — thermostat + thermal fuse mandatory |
| 3 | Never energize a heater without a functional float-switch interlock |
| 4 | Never route mains AC through breadboard or hobby-grade connectors |
| 5 | Always plug into a GFCI outlet |
| 6 | Always bond exposed metal to mains earth |
| 7 | ESP32 watchdog defaults all dangerous outputs OFF on reboot or fault |
| 8 | Fail safe by default: heater off, valve closed (NC), actuator parked |
| 9 | Never leave the machine unattended until many months of reliable operation |
| 10 | Class C fire extinguisher within reach during all mains testing |
How Phase 1 satisfies this
The only mains-connected component is a stock UL-listed appliance behind an inline GFCI, carrying its own thermostat, thermal fuse, and dry-fire interlock (rules 2, 3, 5 by construction). The ESP32 side is entirely low-voltage and mechanically isolated — servo arms pressing plastic buttons, zero electrical contact. Boot-to-rest servo behavior covers rules 7–8: every reset path leaves both arms un-pressed.
Reference
Risk register
Known failure modes, ranked by likelihood × impact, each with a mitigation. Two have already fired — and were handled the way the register said they would be.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Mains/water safety incident | Low | Severe | Independent interlocks, GFCI, earth bond, never unattended early |
| SSR fails closed (heater stuck on) | Medium | High | Heatsink; thermostat + thermal fuse backstop (Phase 1: appliance's own stack) |
| Dispense volume drifts fired — handled | High | Medium | Confirmed ~±15 mL open-loop → accepted for v1; load-cell closed loop is the fix |
| Dose repeatability poor (blade grinder) | High | Low | Accept ±1 g for v1; load cell for by-weight later |
| Cleaning inadequate, machine gets gross | Medium | Medium | Cleanable materials, cold spray, removable parts, weekly manual clean |
| Grinder EMI disrupts sensors | Medium | Medium | Snubbers, wiring separation, separate ground |
| Vibration loosens connections firing now | Medium | Medium | Threadlocker (unlock horn screw drifted post-reboot — fix queued), strain relief, inspection |
| Scope creep stalls the project | High | High | Stage discipline: finish v1 before upgrades |
| Boiler scale buildup | High | Low | Periodic descaling, documented procedure |
| Plunger misalignment under load | Medium | Medium | Rigid cradle, external limit switches, careful Phase 2 tuning |
Reference
Bench reference
Everything needed to drive the Phase 1 rig from a laptop. The device must be on the same LAN — this card is a cheat-sheet, not a remote control.
Device
| What | Where |
|---|---|
Web UI (ESPHome web_server) | http://coffee-bot.local (192.168.1.19) |
| Native API (Pi / aioesphomeapi) | coffee-bot.local:6053 · encrypted, key in secrets.yaml |
| OTA flash | esphome run firmware/coffee-bot.yaml --device coffee-bot.local |
Current calibration 2026-06-08 · persists across reboots
| Entity | Value | Meaning |
|---|---|---|
unlock_press_level | 0.55 | 0.65 overshot the contact arc — threadlock the horn so this holds |
dispense_press_level | 0.45 | held for the full pour; minimal overtravel |
flow_rate_ml_per_sec | 32.0 | steady-state at 196 °F · two-point fit |
dispense_dead_time_ms | 1750 | pump spin-up + priming · keeps small bloom pours proportional |
dispense_volume_ml | 250 | hold = volume/flow + dead-time ≈ 9.6 s |
| Tiger auto-relock | ~10 s | firmware's 300 ms unlock→dispense gap has ~33× margin |
REST quick commands
# trigger a full unlock + dispense cycle
curl -X POST -H "Content-Length: 0" http://coffee-bot.local/button/run_brew_dispense/press
# individual presses (calibration)
curl -X POST -H "Content-Length: 0" http://coffee-bot.local/button/test_unlock_press/press
curl -X POST -H "Content-Length: 0" http://coffee-bot.local/button/test_dispense_press/press
# SAFETY: abort + park both servos at rest
curl -X POST -H "Content-Length: 0" http://coffee-bot.local/button/all_servos_rest/press
# set a number entity (note: POST needs the Content-Length header)
curl -X POST -H "Content-Length: 0" "http://coffee-bot.local/number/dispense_volume_ml/set?value=250"
Endurance run Phase 1 exit gate
scripts/endurance_run.sh # 50 cycles, 30 s pause (defaults)
CYCLES=5 PAUSE=15 scripts/endurance_run.sh # short shakedown
HOST=192.168.1.19 scripts/endurance_run.sh # if mDNS is being mDNS
logs/endurance_*.csv.Firmware workflow
cp firmware/secrets.yaml.example firmware/secrets.yaml # once; fill in WiFi + keys
esphome run firmware/coffee-bot.yaml # first flash over USB
esphome run firmware/coffee-bot.yaml --device coffee-bot.local # OTA thereafter
Safety conventions baked into firmware/coffee-bot.yaml: rest = level 0.0 = boot default (Option A), restore: false, no auto_detach_time (a floppy arm could drift onto a button; a mid-pour detach would cut the dispense), mode: single on brew_dispense so overlapping triggers are ignored, and an on_boot rest re-assert as belt-and-suspenders.