Keep the espresso machine at brew temperature with a periodic purge - #293
Merged
Conversation
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>
Nicolas Palpacuer (NickPPC)
approved these changes
Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
prepareDrinkpokes the brew button and waits outbrew_time_secwithout 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_purgeaction runs clean end to end.Review guide
Start with
coffee/keepalive.go— that's the whole feature. Everything else is wiring.Changes
VIAM_MODULE_DATA.KeepAliveblock; rejects an unset timezone (LoadLocation("")is UTC, not an error) and a too-tightafter_minmargin.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.keepalive_purgeadded toexecute_actionfor on-demand purges (gated on the machine, not onkeepalive, so poses can be verified before the loop is on);actionFuncssplit out so registration is assertable; pose requirements gated onkeepalive.brew(), so a standalonebrew_coffeeaction resets the clock too.Keep-alive purgestep label.A purge announces itself through
speech_service_nameand waits 5 seconds before moving, regardless ofconversational— it is the one arm motion nobody requested, so it is a safety notice rather than status narration.Testing
go test ./coffeeandgo test -race ./coffeepass. 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.{"execute_action": "keepalive_purge"}presses the button and returns home. Planning neededfiltervscoffee-machine-topallowed, which is in the set.make lintcould not run locally — golangci-lint v1.64.8 against a v2 config, pre-existing and unrelated.gofmt -sandgo vet ./...are clean, so CI is the first real lint pass on this code.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