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. - 2026-06-10
Unlock press regressed — second geometry walk in a week; printed clamp-over horn extension under consideration. Site launched at coffeebot.alexmeub.com.
- 2026-06-11
Phase 1 exit gate passed: 50/50 endurance cycles, zero failures, zero interventions. Same day: unlock press re-landed at 0.53; recirc back-pressure leak at the spout gap found and sealed.
- 2026-06-13
Phase 1 fully closed; Phase 2 kicked off. Flow sweep found ~65 mL tank-level drift → fix with app-side tank-level compensation + fill bar (and/or the load cell). Drive-chain parts ordered.
- 2026-06-18
Phase 2 step 1 done: actuator extends/retracts full-stroke under ESP32 control via the BTS7960, Stop halts instantly. Firmware flashed (ESPHome 2026.6.1, OTA).
- 2026-06-23
Phase 2 step 3 done: plunger aligned, press pushes through the chamber with no binding. MVP goes open-loop (limit switches deferred).
- 2026-06-24
Press cycle calibrated dry; brew/clean sequence drafted. Open-loop timed press scripts + bench buttons.
- 2026-06-25
First real brew works. Fast seal plunge + ~5 mm backoff pulls a vacuum that holds the water through a 90 s steep; slow extract presses it through; puck ejects by contact (pneumatic eject didn't pan out).
- next session
Phase 2: re-tune the contact-eject distance + extract closer to the hiss, then the 50-cycle endurance gate.
The discipline
Working log
Build log
Chronological record of the build — progress, issues, and the calls made along the way. Newest first. The overview timeline shows highlights; this is the full story.
-
Filled the AeroPress from the Tiger dispense (250 mL) and brewed end-to-end with the new 3-phase Brew Press. The breakthrough: a fast seal plunge (6000 ms @ 70 %) + a ~5 mm backoff pulls a vacuum that held the water through a full 90 s steep with no overflow — without it the paper filter just drains the pour. That suction-hold is what makes a stationary-chamber auto-AeroPress actually work. The slow extract pressed the coffee through; the hiss came at ~26000 ms (Press Duration set to 20000, just short of it).
Eject finding: the planned pneumatic eject (trapped air pops the puck, plunger stays clean) doesn't work — by eject time the water's pressed through, so the spent damp-grounds puck won't seal the bore and the air just leaks past instead of pushing. Contact ejection is the answer: drive the seal down to push the puck out, accept a little coffee on the silicone seal (the Phase 5 spray clean handles it). This makes the "stop before the hiss for an air gap" idea moot, so the extract can press closer to the hiss for fuller extraction next time.
press_eject_msneeds a wet re-tune as a contact-push distance. -
Wrote the open-loop timed press-cycle firmware (home / descend / press / eject scripts, tunable durations, bench buttons, boot-safe, Stop aborts all) and calibrated it dry against the bench: approach 12000 ms lands the plunger right at the chamber mouth, eject 36000 ms drives through and out the bottom (clean pass), home 50000 ms full return — and the full cycle chains cleanly. All times are tied to the 30 % travel speed (re-scale if that changes).
The real sequence is two stages with a manual step between, not one auto cycle: (1) Brew Press — descend and press to ~80 % / the AeroPress hiss, then stop, leaving an air gap above the puck; (2) the user swaps mug → waste cup and removes the filter cap + bottom plate; (3) Eject & Clean — press a short bit further so the trapped air pneumatically pops the puck out the open bottom, and the seal never touches the grounds — the plunger stays clean. The "hiss" can't be sensed open-loop (approximated by a calibrated depth; current-sense is the honest fix later), and the real eject is a small partial press, not a drive through — so the auto "Full Cycle" is now just a bench-test button.
Press Durationand the partial eject are wet-tuning tasks (need a real brew to press against). Buttons relabeled Brew Press / Eject & Clean / Home Plunger. -
Mount, cradle, and plunger coupler built from printed parts; the plunger is aligned to the chamber and dry test push-throughs run under actuator control with no binding. Alignment was solved by fitting the existing printed parts — after a good run through the options: an offset clevis-hole adapter (dropped — the actuator rod can spin, so a fixed offset would point in a random direction), a single-axis slotted alignment plate + shim, and a funnel + floating coupler for mechanical self-centering. The slotted-plate and funnel/float concepts are saved in
docs/as fallbacks if alignment ever drifts.MVP decision: no external limit switches (step 2 deferred). The press runs open-loop — actuator ends are known via its internal limits, press depth is a timed move homed from full retract. Current sensing on the BTS7960 is the cleaner future upgrade for end-of-press + over-force.
Integration to-do (Phase 4): the dispense hose currently has to be pulled by hand before pressing or it blocks the plunger. The fix is the documented offset pour — mount the hose at a fixed offset, angled into the chamber beside the plunger's column, so nothing moves out of the way (and the off-center pour adds extraction swirl). Next on the bench: press-profile tuning (step 4) and puck ejection (step 5).
-
The whole press drive chain is proven end to end: ESP32 → BTS7960 H-bridge → Firgelli 150 lb / 12 in actuator off the 12 V brick. Wired RPWM/GPIO9 (extend), LPWM/GPIO10 (retract), the tied enables on GPIO11 (boots LOW, fail-safe), and common ground to the ESP32; the brick is spliced straight in (no barrel jack on hand) through the 10 A fuse + kill switch. The 12 V supply was a splice job — verify polarity with a meter, never trust the wire markings.
Firmware added to
coffee-bot.yaml(boot-disabled enable, never-both-directions scripts, jog + run + stop bench buttons) and flashed OTA via a freshly-installed ESPHome 2026.6.1. Result: full-stroke extend and retract under web-UI control, internal end-stops hold both ends, and Actuator Stop halts it instantly mid-travel. No brownout — the 12.5 A brick swamps the 8 A surge. Phase 1 servos untouched. Next: measure the actuator, build the stand, fit the limit switches (step 2). -
Ran the step-8 full→¼ tank sweep (15 weighed pours,
logs/flow_sweep_20260613.csv) — the last Phase 1 loose end. Result: pour volume drifts ~65 mL from full (~312 mL) to ¼ tank (~247 mL), roughly linear at ~17 mL/L drained. That's ~4× the ±15 mL pour-to-pour spec, so the dominant volume error isn't pump spin-up noise — it's tank level. And since ¼ tank is the normal refill point, that whole span is the real operating envelope. The drift is at least systematic and predictable: over-pours when freshly filled, drifts to on-target by refill time.The fix is not re-tuning the open-loop model (it's already stale — upper-tank pours read ~37 mL above the 06-05 calibration, the 06-11 spout/hose rework shifted the plumbing). Two layered fixes instead: (1) app-side feed-forward compensation — the Pi already commands every pour, so it tracks water dispensed since refill, estimates tank level, and adjusts the per-pour dispense time off this measured drift curve; cancels the systematic ~65 mL, leaving ~±15 mL, and gives a tank fill bar + refill prediction in the UI for free. (2) load-cell closed loop — stop the pour at target weight; kills both error sources to ±5 mL and enables by-weight dosing. They stack (feed-forward + feedback), and the load cell stays a deferred upgrade since software comp covers v1.
With this, native-button check, and threadlock all done today, every Phase 1 step is resolved and Phase 2 (press mechanism) is the active phase — drive-chain parts ordered.
-
BOM written (
hardware/phase2-bom.md) and the drive chain ordered: Firgelli 150 lb / 12 V / 12 in actuator (0.30 in/s, IP66, 5 A draw / 8 A surge), BTS7960 (IBT-2) 43 A H-bridge with fail-safe enable wiring, a 12 V / 12.5 A UL-listed sealed brick sized for the surge, and 16 AWG power wire. Base is a butcher-block + braced 2×4/plywood bench fixture (aluminum baseplate deferred to Phase 4 / wet path). Safety adds the scope didn't list: inline 10 A fuse + a 12 V kill switch — this phase's hazard is mechanical crush, not electrical. -
50 consecutive
brew_dispensecycles, zero failures, zero manual interventions, zero safety events — the Phase 1 exit criterion, met. Single ~1 h session over the recirculation loop (20:30–21:28, 43 s/cycle cadence), all 50 REST triggers accepted, servos parked clean, bench dry and tank level constant at the end. Log:logs/endurance_20260611_203015.csv. The unlock press (re-landed at 0.53 the same day) held its geometry through all 50 presses — no drift.Harness gotcha for the record: cycles 16–20 stalled for up to 18 min when the bench Mac napped and suspended the script mid-run. Fix: pin the Mac awake with
caffeinate -i -w <script pid>(now noted in the script header). The stalls were idle gaps between cycles — each dispense itself was unaffected.Phase 1 is done. Loose ends carried forward: confirm the Tiger's native buttons still work under the bracket (step 5), confirm threadlock on the unlock horn, and decide whether the full→¼ tank flow sweep (step 8) still matters now that ±15 mL is the accepted v1 tolerance. Next: Phase 2 — the press mechanism.
-
First smoke cycle of the recirc loop leaked: the uphill tube run to the fill opening puts static head on the spout (the first back-pressure it has ever seen — open-air dispensing never pressurizes it), and water exited through a thin gap along the back of the nozzle that the slip-over hose seal couldn't cover. Silicone caulk was considered and rejected (not food-safe, and it would glue the spring-loaded spout solid).
Fix: relieved the housing plastic around the spout with a Dremel for hose clearance, dealt with the flared features on the nozzle that broke the seal line, and zip-tied the silicone hose over the sealed zone. Smoke cycle after the fix: clean, no leaks, return flowing into the fill opening. Trade recorded: the Tiger is no longer cosmetically unmodified — the spout housing is permanently relieved. Electrical isolation and the appliance's internal safety stack are untouched.
Same session: the 06-10 unlock regression was resolved by re-tuning — unlock press re-landed at 0.53 (third value: 0.65 → 0.55 → 0.53).
-
The unlock servo stopped landing its press again (same mode as the 0.65 → 0.55 drift on 06-05; threadlock was still pending). Diagnosis fork: hand-press the button (rules out the Tiger side) → fire
Test Unlock Pressand watch where the tip lands — short/off-center means the horn screw backed out or the horn re-seated a spline tooth off (~18°/tooth ≈ 0.2 level units, much bigger than the earlier drift); skating past the button means the sweet spot moved again; square press but no unlock means dwell/depth, not geometry. Also check the bracket hasn't shifted on the panel.Likely fix beyond re-tuning: a 3D-printed clamp-over extension on the stock horn (don't print the spline — FDM won't hold the 20-tooth 4.8 mm spline). Paddle or cupped face larger than the button so small drift still presses center-ish; ideally a constant-radius cam face so overtravel adds preload instead of skating off. Add the silicone pad the pin-assignments doc already planned. Threadlock the screw the same session, before the endurance run.
Same-day pre-flight while diagnosing: board up, all five calibration numbers persisted (250 / 32.0 / 1750 / 0.45 / 0.55). Gotcha found: curl on the bench Mac intermittently fails to resolve
coffee-bot.local(ping resolves fine) — run the endurance script withHOST=192.168.1.19. -
This site: phases 0–8 with sub-step statuses, per-phase wiring diagrams, decisions log, pinout/BOM/safety/risk reference, bench cheat-sheet. Static single file, S3 + CloudFront, deployed via
webapp/deploy.sh. -
scripts/endurance_run.sh: fires Nbrew_dispensecycles over REST with CSV logging, pre-flight reachability check, consecutive-failure abort, Ctrl-C parks the servos. Assumes a recirculation loop (spout tube routed back into the Tiger's fill opening) so 50 cycles don't need 12.5 L of catch vessel. Smoke test: cycle 1 ok. -
Temperature monitoring removed from firmware — the UL-listed Tiger self-regulates and nothing in the dispense path consumes the reading (the bench probe had also died after a water dunk). And the measured ~±15 mL open-loop pour spread is accepted for v1: it's pump spin-up variance + tank drift, untunable by timing, ~6% of the water. ±5 mL deferred to a load-cell + HX711 closed loop. Details in the decisions log.
-
Compiled (ESPHome 2026.5.2), flashed over UART, on WiFi at
coffee-bot.local; OTA from here on. Servos calibrated in-place against the Tiger's buttons (unlock 0.55, dispense 0.45 — 0.65 overshot the unlock contact arc after a reboot). Dead-time dispense model fitted: 32.0 mL/s + 1750 ms, centered ~250 mL. Auto-relock measured ~10 s — the 300 ms unlock→dispense gap has ~33× margin. Gotcha of the day: servo PSU ground must tie to ESP32 ground — without it both servos spin continuously. -
Both MG90S units press the Tiger's buttons with force to spare (standalone servo-tester rig) — the button-press architecture is feasible before committing to bracket design. Parametric bracket modeling started in Fusion 360; decided on stock horns with arc-tuned press geometry.
-
A UL-listed appliance takes over all thermal regulation and mains safety — trades arbitrary setpoints for fixed ones (196 °F is well-centered for AeroPress) and cuts most of the Phase 1 electrical risk and timeline.
Photos
Build photos
The build in pictures, oldest first — from Phase 1 wiring to the first real brew. Click any photo to zoom (arrow keys navigate). Originals plus a couple of clips live in the repo's images/build/ folder.
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 · complete
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.
logs/endurance_20260611_203015.csv. See the build log.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 electrically untouched — no soldering, no teardown, its internal safety stack intact. One permanent cosmetic mod (06-11): the housing plastic around the spout was relieved with a Dremel so the recirc/dispense hose can seal past a gap in the nozzle.
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
Third value (0.65 → 0.55 → 0.53) after the 06-10 geometry walk. Held through all 50 endurance presses with no drift.
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.
Measured data from logs/ · re-render with scripts/render_charts.py
Step-8 sweep (15 weighed pours, logs/flow_sweep_20260613.csv): pour volume tracks tank level almost linearly — the systematic ~65 mL drift the Phase 6 app-side compensation will cancel. Scatter at a fixed level is the accepted ±15 mL.
Exit-gate endurance run (logs/endurance_20260611_203015.csv): 50/50 cycles at a steady ~43 s cadence. The spikes are the bench Mac napping between cycles — idle gaps, not rig failures; the harness now runs under caffeinate.
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).
-
done
5. Servo press calibration + validation
Servos mounted and calibrated in-place via the web UI test buttons: dispense 0.45, unlock now at 0.53 — third value after two geometry walks (0.65 → 0.55 → 0.53; the 06-10 regression was resolved by re-tuning, 06-11). The fix held through all 50 endurance presses with no drift. Closed 2026-06-13: unlock horn threadlocked, and the Tiger's native buttons confirmed still working 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). Sealing fix (06-11): the recirc back-pressure leaked through a gap in the spout's nozzle — housing relieved for hose clearance, flares addressed, hose zip-tied over the sealed zone. No leaks through the 50-cycle run.
-
done
8. Flow-rate characterization
Steady-state flow 32.0 mL/s and 1750 ms dead-time fitted at 196 °F. Full→¼ tank sweep done 2026-06-13 (15 pours, weighed;
logs/flow_sweep_20260613.csv): pour volume drifts ~65 mL from full (~312 mL) to ¼ tank (~247 mL), roughly linear at ~17 mL/L drained — ~4× the ±15 mL spec, so tank level (not spin-up noise) is the dominant volume error across a refill cycle. The drift is systematic and predictable (over-pours when freshly filled, on-target near refill), and ¼ tank ≈ the normal refill point, so this span is the real operating envelope. Upper-tank pours read ~37 mL above the 06-05 calibration → the 06-11 spout/hose rework shifted the plumbing; the open-loop model is stale. Fix is not open-loop re-tuning — it's app-side feed-forward tank-level compensation (near-term) and/or the load-cell closed loop (precision). See the decisions log. -
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).
-
done
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. Passed 2026-06-11: 50/50 cycles in a single ~1 h session over the recirc loop — zero failures, zero interventions, no leaks, no servo drift, bench dry at the end. Log:logs/endurance_20260611_203015.csv. (Run the script undercaffeinate— a sleeping Mac suspends it mid-run.)Exit: 50 consecutive successful cycles, no manual intervention, no safety events. ✓ Phase 1 complete.
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 · current focus
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 Firgelli 12 V linear actuator (12 in stroke, 150 lb force, 0.30 in/s, IP66) hangs above the cradle 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, with the enable line defaulting low at boot so the actuator can't move until firmware allows it; top and bottom limit switches feed the ESP32 as interlocks. The AeroPress sits in a rigid printed cradle on a butcher-block + braced 2×4/plywood bench fixture that has to shrug off 30–40+ lb of press force without flexing (the anodized-aluminum baseplate is deferred to Phase 4, where it joins the permanent wet path). Power is a 12 V / 12.5 A UL brick through an inline 10 A fuse and a manual kill switch.
Build sequence
-
done
1. Wire actuator + H-bridge + 12 V supply
Verify extend/retract under ESP32 control. Done 2026-06-18: BTS7960 wired to GPIO9 (RPWM/extend), GPIO10 (LPWM/retract), GPIO11 (tied R_EN+L_EN, boots LOW), common ground to the ESP32; 12 V/12.5 A brick spliced through a 10 A fuse + kill switch to B+. Firmware flashed (ESPHome 2026.6.1, OTA). Actuator extends and retracts full-stroke under web-UI control, the internal end-stops hold both ends, and Actuator Stop halts it instantly mid-travel (drops the enable → motor coasts). No brownout — the 12.5 A brick has ample headroom over the 8 A surge.
-
deferred · MVP
2. Add limit switches as hard interlocks
Deferred for the MVP (2026-06-23): the press runs open-loop — the actuator knows its two ends via internal limit switches, and press depth is a timed move (0.30 in/s) homed from full retract. External switches aren't wired. The cleaner future upgrade is current sensing on the BTS7960 (R_IS/L_IS) for end-of-press-by-load + over-force protection — see the build log. Drawing kept:
docs/phase2-actuator-limit-switch-layout.svg. -
done
3. Mount AeroPress in cradle, couple the plunger
Verify alignment and zero binding through the full stroke. Done 2026-06-23: mount, cradle, and plunger coupler built from printed parts; plunger aligned to the chamber and dry test push-throughs run under actuator control with no binding. (Alignment was solved mechanically by fitting the existing parts — the offset-adapter idea was dropped once it was clear the actuator rod can spin, which would point a fixed offset in a random direction. The slotted-plate and funnel/float concepts in
docs/are kept as fallbacks.) -
in progress
4. Tune press profile — 3-phase, wet-validated
Validated on a real 250 mL brew (2026-06-25). The Brew Press is 3 phases: (1) Seal Plunge — fast (6000 ms @ 70 %) to ~7 mm past the mouth, to engage the seal before the filter drains; (1b) Backoff — a ~5 mm retract that pulls a vacuum holding the water through the full 90 s steep (the key enabler — without it the pour just drains out); (3) Extract — slow (30 %) press toward the hiss (found ~26000 ms; Press Duration set to 20000). "Hiss" can't be sensed open-loop (calibrated depth; current-sense is the honest detector later); the press is much slower under brew back-pressure (~1.3–2.2 mm/s vs ~3.3 free-air).
-
in progress
5. Puck ejection — contact push
Revised after the wet test (2026-06-25). The planned pneumatic eject (leave an air gap, let trapped air pop the puck so the plunger stays clean) doesn't work — the spent, drained puck won't seal the bore, so the air leaks past instead of pushing. Contact ejection is the answer: drive the seal down to push the puck out the open bottom; a little coffee ends up on the food-grade silicone seal, which the Phase 5 spray clean handles. The puck did eject this way.
press_eject_msneeds a wet re-tune as a contact-push distance. -
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.
Concept drawings docs/ — to scale · fallbacks + future upgrades
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.
The target
The concept render this phase aims at — the machine the bench rig grows into.
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 bench rig — Phase 1 dispense and the Phase 2 press — 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-11 · persists across reboots
| Entity | Value | Meaning |
|---|---|---|
unlock_press_level | 0.53 | third value (0.65 → 0.55 → 0.53); survived the 50-cycle endurance run with no drift |
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 |
Phase 2 press calibration 2026-06-25 · persists on-device
| Entity | Value | Meaning |
|---|---|---|
seal_plunge_ms @ 70 % | 6000 | fast descent, home → ~7 mm past the mouth — engages the seal before the filter drains the pour |
seal_backoff_ms @ 30 % | 1500 | ~5 mm retract — pulls the vacuum that holds the water through the steep |
steep_ms | 90000 | no motion; the suction does the holding |
press_duration_ms @ 30 % | 20000 | slow extract from the seal position; hiss found ~26000 — next tune can press closer (contact eject made the air-gap concern moot) |
press_eject_ms @ 30 % | stale | re-tune wet as a contact-push distance — the stored ~4000 ms is the failed pneumatic value |
press_home_ms @ 30 % | 50000 | bounds the retract; the actuator's internal top limit is the real stop |
actuator_jog_ms | ≤ 5000 | jog burst length — sets above 5000 are silently rejected (number max_value clamp) |
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"
# Phase 2 press — object_ids are NAME-derived, not script ids
curl -X POST -H "Content-Length: 0" http://coffee-bot.local/button/brew_press/press
curl -X POST -H "Content-Length: 0" http://coffee-bot.local/button/eject___clean/press # "Eject & Clean": & and spaces each become _
curl -X POST -H "Content-Length: 0" http://coffee-bot.local/button/home_plunger/press
curl -X POST -H "Content-Length: 0" http://coffee-bot.local/button/actuator_stop/press # software stop — the kill switch is the real one
curl -X POST -H "Content-Length: 0" "http://coffee-bot.local/number/press_duration_ms/set?value=20000" # "Press Duration MS" → press_duration_ms (NOT press_dur_ms)
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.