Skip to content

Feature: Pour-Over Meticulous Machine Integration (Ratio Mode) #237

@hessius

Description

@hessius

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

  1. User is in Pour Over → Ratio mode
  2. User toggles "Meticulous Integration" (new toggle, alongside existing auto-start toggle)
  3. User sets dose, brew ratio, and bloom settings as usual → target weight is calculated
  4. User presses Start
  5. MeticAI:
    • Adapts PourOverBase.json with calculated target weight and bloom settings
    • Creates a temporary profile on the machine
    • Loads and starts the profile
  6. Machine chimes → user starts pouring
  7. User follows the pour on both the machine display and the MeticAI app (live weight, flow rate, timer via WebSocket)
  8. Machine reaches target weight → shot completes automatically
  9. MeticAI auto-triggers purge → deletes temporary profile
  10. 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:

  1. Reads PourOverBase.json from disk (no LLM call)
  2. Applies deterministic parameter substitution
  3. Calls TempProfileService.create_and_load()
  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions