Skip to content

Keep the espresso machine at brew temperature with a periodic purge - #293

Merged
Vijay Vuyyuru (vijayvuyyuru) merged 18 commits into
mainfrom
worktree-machine-keepalive
Aug 27, 2026
Merged

Keep the espresso machine at brew temperature with a periodic purge#293
Vijay Vuyyuru (vijayvuyyuru) merged 18 commits into
mainfrom
worktree-machine-keepalive

Conversation

@vijayvuyyuru

@vijayvuyyuru Vijay Vuyyuru (vijayvuyyuru) commented Aug 26, 2026

Copy link
Copy Markdown
Member

Summary

prepareDrink pokes the brew button and waits out brew_time_sec without ever checking whether the machine responded. The BES920 sleeps after an hour idle, and a brew on a sleeping machine is refused with three beeps — so the arm waits against a dry group head and serves an empty cup, recorded as a success everywhere downstream. This adds an opt-in loop that periodically holds the 1 CUP button (Breville's own documented group-head purge) to reset the machine's idle timer, so it never leaves brew temperature during working hours.

The purge motion is verified on hardware. Poses are authored and the keepalive_purge action runs clean end to end.

⚠️ Open-loop by design — it cannot recover a machine that is genuinely powered off, so if Auto Start fails to fire, orders still brew cold. Requires has_separate_brew_buttons plus purge_approach/purge_press on the filter switcher; with keepalive unset, nothing changes.

Review guide

Start with coffee/keepalive.go — that's the whole feature. Everything else is wiring.

Changes

  • coffee/keepalive.go: schedule parsing, DST-correct window membership, the purge decision, the four-move purge (standoff → press → retreat → home), the ticker, and activity persistence in VIAM_MODULE_DATA.
  • coffee/config.go: the KeepAlive block; rejects an unset timezone (LoadLocation("") is UTC, not an error) and a too-tight after_min margin.
  • coffee/collisions.go: filterCoffeeButtonCollisions — the filter alone may enter the machine's front buffer and top, on the two linear moves only. The handle, claws, and gripper stay hard obstacles.
  • coffee/espresso.go: keepalive_purge added to execute_action for on-demand purges (gated on the machine, not on keepalive, so poses can be verified before the loop is on); actionFuncs split out so registration is assertable; pose requirements gated on keepalive.
  • coffee/brew_steps.go: activity recorded in brew(), so a standalone brew_coffee action resets the clock too.
  • coffee/module.go, api.go: loop startup and the Keep-alive purge step label.
  • README.md: config reference, the new poses, and the operator's Auto Start step.

A purge announces itself through speech_service_name and waits 5 seconds before moving, regardless of conversational — it is the one arm motion nobody requested, so it is a safety notice rather than status narration.

Testing

  • go test ./coffee and go test -race ./coffee pass. Subtests cover the window (including an EST/EDT pair proving it is not a fixed UTC offset), validation, persistence, the purge decision table, the collision scope, and action registration across both machine types.
  • Verified on hardware: {"execute_action": "keepalive_purge"} presses the button and returns home. Planning needed filter vs coffee-machine-top allowed, which is in the set.
  • make lint could not run locally — golangci-lint v1.64.8 against a v2 config, pre-existing and unrelated. gofmt -s and go vet ./... are clean, so CI is the first real lint pass on this code.
  • Still to confirm before the loop is switched on: that a 1-second hold actually starts the pump. purge() reports success on motion alone, so too short a hold would reset nothing while looking like it worked. Breville's documented purge is 5 seconds.
Claude Code prompts used
  • "We are using a breville machine for making cofee: https://www.breville.com/en-us/product/bes920. I want to solve a problem. The machine auto turns off after some period of inactivity. This is fine overnight and on weekends. However, I think on working days, the arm should turn the machine on by hitting the power button. But we need to also manage the state correctly. I.e it only turns off if its been more than an hour since our last coffee or 4 hours. Also, once we turn it on, the machine has to warm up. The state of the power button is given by the status of the light around the power button. If the machine is off, we should lock the portafilter then hit the button and pick the portafilter after and go home. Use the brainstroming skill and check the manual for the full status. The power button appraoch will be a value on the claw switch while the observe pose is a filter switch. We also need to figure out how to detect this via a realsense attached to the arm. Or maybe a seperate webcam. But vision is important and key. Plan this out, ask questions about unclear and propse how we can do the vision"
  • "I think this makes sense but I have a few questions. 1. Are you saying we should have something check on a schedule? Theres a trade off on doing this vs just checking at the start of a brew. What are teh tradeoffs 2. Can you talk about the difference btween power save, standby and off? So the off is handled entirely by the machine itself. But we can tell we're in powersave cause its off and if so, click a single cup button to wake?"
  • "What if we change this. Enable auto start on the machine itself. Then, just prevent the machine from going into standby when in certain time windows. We should compare against the last time we brewed something (persisted through reconfigures, maybe by writing smth to disk) every configurable num minutes. If we are brewing or time since is <50 min, sleep until next tick. If time >=50 min, lock portafilter, press manual button twice quickly, then unlock portafilter and go home. That seems simpler overall. Its probably still worth having a machine state sensor later but IDRK if we need this right now."
  • "we dont nee da data dir, there should be a Viam enrivonment variable for a dir a module can write to. Check the docs for this. Additionally, we actually dont need the manual. If we press and hold the espresso button for 2 seconds, it does the same w/ no new poses at all."
  • "Why do we care about the difference between outside vs not doing anyuthing about it? I also think we should just set it to be the autostart time whenever that is via config param. Also, sure we could do the menu button it just seems easier to not do that. Can you do a scan of the manual or online forums ot check if hitting th emenu buttons resets this time?"
  • "Ok great, the hold duration will be quite short. This looks good, create a new worktree"
  • "give me the full patht o thes pec"
  • "alright looks good, go ahead"
  • "whatever is faster"
  • "create a pr"
  • "remove the spec and plan from this file"
  • "What are the new poses"
  • "where is this worktree located on disk"
  • "alright ive set the pose, on the filter switch what other config changes ar eneeded to test this"
  • "Yea add ana ction for this."
  • "also, should we edit the logic to be last succesful coffee brew? Some actions have nothign to do w/ keeping the machine on. And that is our strongest guarantee?"
  • "Merge in latest main to this branch"
  • "what is the do command to trigger the action and to what component?"
  • "I got htis error: ConnectError: [unknown] keepalive_purge: move to "purge_press" failed: motion planning failed: all IK solutions failed constraints. Failures: { robot constraint: violation between coffee-machine-top_origin and filter_origin geometries: 100.00% },. Seems like we are hitting the top as well. Can you add this as an allowed collision on the move to purge press pose?"
  • "wait i dont want the handle, just the filter."
  • "wait a second, did you add the gripper and claws to both? hell now jut let the filter collide w. the top and other allowed collisions during the purge press move"
  • "ok great the action itself worked. now give me the config for the auto tracking so I can check that"
  • "ok great what are the real values to use"
  • "I also think we should add an announcement via voice that its going to move and wait 5 seconds, then move. Something like I am going to keep the coffee machine on to that effect"
  • "push this and update the pr"

The coffee service brews without checking whether the machine is at
temperature, so a sleeping machine yields an empty cup recorded as a
success. Rather than sense-and-recover, keep the machine from ever
leaving brew temperature: the machine's own Auto Start covers the cold
morning, and a periodic short hold of the 1 CUP button — with the
portafilter still in the claws, on a new filter-switch pose — resets its
1-hour idle timer during configured windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six TDD tasks: window parsing and DST-correct membership, config
validation and pose gating, last-activity persistence in
VIAM_MODULE_DATA, the three-move purge sequence, the purge decision and
execution, then the loop and its wiring. Each task ends with a runnable
test and a commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The window is resolved once into a location plus minutes-since-midnight
so the tick does no parsing, and membership converts the instant into the
window's location before comparing wall-clock minutes — which is what
keeps a configured 07:45 open meaning 07:45 local across DST.

An empty timezone is rejected explicitly: time.LoadLocation("") resolves
to UTC without error, so an operator who omitted the field would
otherwise get UTC hours silently rather than being told.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The margin rule is the load-bearing one: the machine sleeps after about
60 idle minutes, so after_min plus two tick periods has to stay under 55
or a single tick skipped by a running order drops the machine to power
save. Pose requirements are gated on keepalive being configured, since
requiring them unconditionally would fail construction on every machine
that will never travel to them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The value is last *activity*, not last brew: a purge is itself activity,
so tracking only brews would re-fire every tick until someone ordered a
coffee. Persisting matters because the service is AlwaysRebuild — without
it, an afternoon of config edits fires a spurious purge per reload. Every
read failure degrades to "long idle" rather than failing construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three filter-switch moves: standoff, press-and-dwell, retreat. Only the
two linear moves may allow coffee-machine-buffer-front — an allowance
covers the whole trajectory, so carrying it on the free-planned approach
would let the planner route the portafilter through the machine's front
face. Same trap brewButtonSteps documents, same test shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shouldPurge is pure over a snapshot so every skip reason is testable and
every skip is logged with its reason — a silent loop is indistinguishable
from a broken one. runPurge takes the same running CAS an order takes, so
the queue treats a purge exactly like an order, and releases it on every
path under a timeout: holding that flag would stall the queue for good.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The loop watches queueStop rather than cancelCtx: an operator cancel
pauses the queue instead of shutting the service down, and shouldPurge
already declines while paused, so the loop resumes on its own after
'proceed'. Reading cancelCtx bare in the select would also race with
signalCancel rotating it; runPurge snapshots it under the mutex instead.

Activity is recorded off a successful brew rather than a successful
order, since a shot that poured and then failed at serving still reset
the machine's timer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were working documents for building the feature, not a reference the
repo needs to carry. The reasoning they held that still matters lives in
the code comments and commit messages: why the purge poses are on the
filter switch, why the approach step must not carry the collision
allowance, why last activity rather than last brew, and why the loop
watches queueStop instead of cancelCtx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying the two new purge poses otherwise meant configuring a
one-minute keep-alive loop and waiting for it to decide, rather than
driving one motion the way every other pose in this repo is checked.

The motion splits out of runPurge as purge(ctx, cancelCtx): executeAction
already takes the running gate and snapshots cancelCtx before dispatch,
so a purge that took the gate itself would deadlock against it. The
action is gated on has_separate_brew_buttons rather than on keepalive
being set, so poses can be checked before the loop is switched on, and it
is never offered on the toggle machine where holding the switch pours a
dose. purgeHold falls back to the default when keepalive is nil.

Recording activity moves into purge, so a hand-triggered purge also
pushes the next tick out by the full threshold — it is just as much
machine use as a scheduled one.

actionFuncs is split from executeAction so which actions exist is
assertable without a constructed service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A standalone brew_coffee or brew_lungo action pours water but was not
resetting the keep-alive clock, since only prepareDrink recorded it. Every
caller routes through brew(), so recording there covers the queued order
and a hand-driven brew step alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 1 CUP button sits high on the machine face, so reaching it puts the
filter inside the modeled machine top even though the real one clears it:
planning failed with a 100% violation between filter_origin and
coffee-machine-top_origin.

Only the two linear moves carry the set, so the grant covers a straight
line onto the button and back rather than a free traverse the planner
could route through the machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only the filter needs to enter the modeled top to reach the button; the
handle sits behind it, so leaving it as a hard obstacle keeps the
allowance to the one geometry that has to be there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The claws and gripper pairs were carried over from
clawCoffeeButtonCollisions on the assumption the claws would also sit
inside the front buffer; the planner never asked for them. Only the filter
has to enter the machine's modeled bulk to reach the button, so the handle,
claws, and gripper stay hard obstacles and a plan that would drive any of
them into the machine is still rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The purge ended at the button standoff, leaving the arm parked at the
machine face — only the failure path went home. The arm's resting pose is
home and every other sequence returns there, so the next order would
otherwise plan from an unusual configuration.

Home is a free traverse: no linear constraint and no allowances, so it
plans clear of the machine with the free-move collision buffer rather than
being permitted to route the return through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other arm motion in this service answers a request someone just
made; a purge fires on a timer, so whoever is standing at the machine has
no reason to expect it. The warning goes through sayAlways rather than
say — it is a safety notice, not status narration, so conversational mode
must not silence it — and the wait is cancellable, so an operator cancel
during the window aborts before anything moves.

The line is queued asynchronously, so the 5 seconds start when the speech
service accepts it rather than when it finishes playing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment volume in keepalive.go drops from 170 lines to 86, keeping the
non-obvious reasoning — the whole-plan scope of allowed collisions, why
LoadLocation("") has to be rejected, why the loop watches queueStop rather
than cancelCtx, why the margin limit exists — and cutting the prose around
it.

Also restores the purgeSteps doc comment, which had been orphaned above
purgeHold when that helper was inserted, leaving purgeSteps undocumented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vijayvuyyuru
Vijay Vuyyuru (vijayvuyyuru) requested a review from a team August 27, 2026 19:29
@vijayvuyyuru
Vijay Vuyyuru (vijayvuyyuru) merged commit e967eb1 into main Aug 27, 2026
4 checks passed
@vijayvuyyuru
Vijay Vuyyuru (vijayvuyyuru) deleted the worktree-machine-keepalive branch August 27, 2026 19:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants