Description
Add a Meticulous machine integration toggle to PourOverView's ratio mode. When enabled, MeticAI adapts the PourOverBase.json template profile to the user's parameters (target weight, bloom settings), uploads it as a temporary profile to the machine, starts the shot, and auto-cleans up afterward.
This gives the user the full Meticulous pour-over experience: weighing, shot tracking, and automatic purging — all driven from the MeticAI app.
No LLM/Gemini calls involved — this is pure parameter adaptation (read template, substitute values, upload).
Incorporates the temporary profile lifecycle originally described in #232.
User Experience
Flow
- User is in Pour Over → Ratio mode
- User toggles "Meticulous Integration" (new toggle, alongside existing auto-start toggle)
- User sets dose, brew ratio, and bloom settings as usual → target weight is calculated
- User presses Start
- MeticAI:
- Adapts
PourOverBase.json with calculated target weight and bloom settings
- Creates a temporary profile on the machine
- Loads and starts the profile
- Machine chimes → user starts pouring
- User follows the pour on both the machine display and the MeticAI app (live weight, flow rate, timer via WebSocket)
- Machine reaches target weight → shot completes automatically
- MeticAI auto-triggers purge → deletes temporary profile
- Pour-over view shows completion summary
Toggle Behavior
| Mode |
auto-start toggle |
Meticulous integration toggle |
| Free mode |
✅ Available |
❌ Hidden (no target weight) |
| Ratio mode — standalone |
✅ Active (default) |
❌ Off |
| Ratio mode — integrated |
❌ Disabled (machine handles timing) |
✅ On |
When Meticulous integration is ON:
- Auto-start is automatically disabled (machine controls the flow)
- The Start button text changes to reflect machine integration (e.g. "Start on Machine")
- Timer, weight, and flow data come from the machine via WebSocket instead of local tracking
Profile Adaptation Logic
Source template: data/PourOverBase.json
No AI/Gemini calls — this is deterministic parameter substitution.
Parameters to Adapt
Given user inputs: targetWeight (g), bloomEnabled (bool), bloomSeconds (number)
| Field |
Base Value |
Adapted Value |
name |
"MeticAI Ratio Pour-Over" |
"[Temp] Pour Over ({targetWeight}g)" |
final_weight |
300 |
targetWeight |
Stage 2 exit_triggers[0].value |
300 |
targetWeight |
Stage 2 name |
"Infusion (300g)" |
"Infusion ({targetWeight}g)" |
Stage 1 name |
"Bloom (30s)" |
"Bloom ({bloomSeconds}s)" (if bloom enabled) |
Stage 1 exit_triggers[0].value |
30 |
bloomSeconds (if bloom enabled) |
| Stage 1 (entire stage) |
Present |
Removed if bloom disabled |
Adaptation Function
// Frontend utility — pure parameter substitution, no LLM
function adaptPourOverProfile(
base: PourOverProfile,
params: { targetWeight: number; bloomEnabled: boolean; bloomSeconds: number }
): PourOverProfile {
const adapted = structuredClone(base);
adapted.name = `[Temp] Pour Over (${params.targetWeight}g)`;
adapted.final_weight = params.targetWeight;
if (!params.bloomEnabled) {
// Remove bloom stage (first stage)
adapted.stages = adapted.stages.slice(1);
} else {
// Adapt bloom stage
adapted.stages[0].name = `Bloom (${params.bloomSeconds}s)`;
adapted.stages[0].exit_triggers[0].value = params.bloomSeconds;
}
// Adapt infusion stage (last stage after potential bloom removal)
const infusionStage = adapted.stages[adapted.stages.length - 1];
infusionStage.name = `Infusion (${params.targetWeight}g)`;
infusionStage.exit_triggers[0].value = params.targetWeight;
return adapted;
}
A corresponding Python backend adaptation function follows the same logic for validation/testing.
Implementation Plan
1. Backend: TempProfileService (reusable service)
Create apps/server/services/temp_profile_service.py:
class TempProfileService:
"""Manages temporary profile lifecycle: create → load → start → purge → delete.
Designed to be reusable — the pour-over feature is the first consumer,
but recipes and other features can use this service directly.
No LLM calls — operates on pre-built profile JSON.
"""
TEMP_PREFIX = "[Temp] "
_active_temp_profile: Optional[dict] = None # {id, name, created_at}
async def create_and_load(self, profile_json: dict) -> dict:
"""Create a temporary profile and load it on the machine.
- Validates the profile JSON has required fields
- Creates via async_create_profile()
- Loads via async_load_profile_by_id()
- Stores reference for cleanup
"""
async def cleanup(self) -> dict:
"""Post-shot cleanup: purge → delete temp profile → clear state."""
async def force_cleanup(self) -> dict:
"""Emergency cleanup: delete without purging (for aborted shots)."""
def get_active(self) -> Optional[dict]:
"""Return the currently active temporary profile, or None."""
async def cleanup_stale(self) -> int:
"""Remove orphaned [Temp] profiles from previous crashed sessions.
Called during startup.
"""
2. Backend: Pour-Over Adaptation Endpoint
In a new (or existing) route module:
POST /api/pour-over/prepare
Body: { targetWeight: number, bloomEnabled: bool, bloomSeconds: number }
This endpoint:
- Reads
PourOverBase.json from disk (no LLM call)
- Applies deterministic parameter substitution
- Calls
TempProfileService.create_and_load()
- Returns
{ profileId, profileName, status: "ready" }
3. Backend: Temp Profile Management Endpoints
| Endpoint |
Purpose |
POST /api/temp-profile/cleanup |
Post-shot purge + delete |
POST /api/temp-profile/force-cleanup |
Emergency delete only |
GET /api/temp-profile/active |
Current temp profile info (or 404) |
4. Frontend: PourOverView Changes
- Add
meticulousIntegration boolean state (default: false)
- Toggle UI in ratio mode settings section
- When integration is ON, disable auto-start toggle
- Start button → calls
/api/pour-over/prepare + startShot() instead of local timer
- Monitor
machineState.brewing transition true → false for cleanup trigger
- Switch weight/timer/flow displays to machine WebSocket data when integrated
5. Startup Cleanup
During FastAPI lifespan startup, call TempProfileService.cleanup_stale() to remove orphaned [Temp] profiles.
Error Handling
| Scenario |
Handling |
| Machine offline |
Disable Start button, show error |
| Profile creation fails |
Error toast, remain on settings |
| Shot aborted by user |
Call force-cleanup (delete without purge) |
| Machine disconnects mid-brew |
Reconnect, retry cleanup when back online |
| Duplicate temp profile name |
Include timestamp in name for uniqueness |
| Purge fails |
Log warning, still delete the profile |
| Delete fails |
Log error, return failure — user may need manual cleanup |
| Stale profiles from crash |
Cleaned up automatically on next startup |
Reusability for Recipes
The TempProfileService is deliberately generic:
- Accepts any profile JSON (not pour-over specific)
- The
[Temp] prefix and lifecycle (create → load → cleanup) work for any temporary profile
- Future recipe feature: generate profile JSON from recipe params → pass to
TempProfileService.create_and_load() → same lifecycle
The adaptation logic is the only pour-over-specific part — recipes will have their own adaptation, but share the same service.
Testing Requirements
Backend
TempProfileService: create, cleanup, force-cleanup, stale cleanup, error paths
- Pour-over adaptation function: all parameter combos, bloom on/off, edge cases
POST /api/pour-over/prepare: happy path, invalid params, machine offline
- Temp profile endpoints: cleanup, force-cleanup, active
Frontend
adaptPourOverProfile() unit tests: all combinations
- Toggle visibility: hidden in free mode, visible in ratio
- State transitions: integration toggle ↔ auto-start interaction
- Mock WebSocket data for shot monitoring
- Error state rendering
E2E
- Full flow with mocked machine in
e2e/pour-over.spec.ts
Dependencies
async_create_profile() ✅ (exists in meticulous_service.py)
async_delete_profile() ✅ (exists in meticulous_service.py)
async_load_profile_by_id() ✅ (exists in meticulous_service.py)
DuplicateProfileNameError ✅ (exists in meticulous_service.py)
- MQTT command publishing ✅ (exists in
commands.py)
- WebSocket live telemetry ✅ (exists in
websocket.py)
PourOverBase.json template ✅ (exists in data/)
useMachineActions hook ✅ (exists in frontend)
mqttCommands.ts ✅ (startShot, purge, loadProfile)
Out of Scope (Future)
- Pour-over recipe library (pre-built profiles for specific beans)
- AI-generated pour-over profiles (analyze bean → suggest technique)
- Temperature control integration
- Multiple pour stages (pulse pouring)
Description
Add a Meticulous machine integration toggle to PourOverView's ratio mode. When enabled, MeticAI adapts the
PourOverBase.jsontemplate profile to the user's parameters (target weight, bloom settings), uploads it as a temporary profile to the machine, starts the shot, and auto-cleans up afterward.This gives the user the full Meticulous pour-over experience: weighing, shot tracking, and automatic purging — all driven from the MeticAI app.
No LLM/Gemini calls involved — this is pure parameter adaptation (read template, substitute values, upload).
Incorporates the temporary profile lifecycle originally described in #232.
User Experience
Flow
PourOverBase.jsonwith calculated target weight and bloom settingsToggle Behavior
When Meticulous integration is ON:
Profile Adaptation Logic
Source template:
data/PourOverBase.jsonNo AI/Gemini calls — this is deterministic parameter substitution.
Parameters to Adapt
Given user inputs:
targetWeight(g),bloomEnabled(bool),bloomSeconds(number)name"MeticAI Ratio Pour-Over""[Temp] Pour Over ({targetWeight}g)"final_weight300targetWeightexit_triggers[0].value300targetWeightname"Infusion (300g)""Infusion ({targetWeight}g)"name"Bloom (30s)""Bloom ({bloomSeconds}s)"(if bloom enabled)exit_triggers[0].value30bloomSeconds(if bloom enabled)Adaptation Function
A corresponding Python backend adaptation function follows the same logic for validation/testing.
Implementation Plan
1. Backend:
TempProfileService(reusable service)Create
apps/server/services/temp_profile_service.py:2. Backend: Pour-Over Adaptation Endpoint
In a new (or existing) route module:
This endpoint:
PourOverBase.jsonfrom disk (no LLM call)TempProfileService.create_and_load(){ profileId, profileName, status: "ready" }3. Backend: Temp Profile Management Endpoints
POST /api/temp-profile/cleanupPOST /api/temp-profile/force-cleanupGET /api/temp-profile/active4. Frontend: PourOverView Changes
meticulousIntegrationboolean state (default:false)/api/pour-over/prepare+startShot()instead of local timermachineState.brewingtransitiontrue → falsefor cleanup trigger5. Startup Cleanup
During FastAPI lifespan startup, call
TempProfileService.cleanup_stale()to remove orphaned[Temp]profiles.Error Handling
force-cleanup(delete without purge)Reusability for Recipes
The
TempProfileServiceis deliberately generic:[Temp]prefix and lifecycle (create → load → cleanup) work for any temporary profileTempProfileService.create_and_load()→ same lifecycleThe adaptation logic is the only pour-over-specific part — recipes will have their own adaptation, but share the same service.
Testing Requirements
Backend
TempProfileService: create, cleanup, force-cleanup, stale cleanup, error pathsPOST /api/pour-over/prepare: happy path, invalid params, machine offlineFrontend
adaptPourOverProfile()unit tests: all combinationsE2E
e2e/pour-over.spec.tsDependencies
async_create_profile()✅ (exists inmeticulous_service.py)async_delete_profile()✅ (exists inmeticulous_service.py)async_load_profile_by_id()✅ (exists inmeticulous_service.py)DuplicateProfileNameError✅ (exists inmeticulous_service.py)commands.py)websocket.py)PourOverBase.jsontemplate ✅ (exists indata/)useMachineActionshook ✅ (exists in frontend)mqttCommands.ts✅ (startShot, purge, loadProfile)Out of Scope (Future)