Pouring Operations

Introduction

The Dispense SDK provides multiple approaches to executing pour operations, each designed for specific use cases. This guide covers pour patterns for both consumer UIs and NCUI/admin applications, including standard nozzle-based pours and generic pour operations for custom endpoints.

Pour Patterns Overview

PatternModelUse CaseKey Method
Nozzle PourNozzleModelConsumer UI selection-based pourspour(), fixedPour()
Pump PourPumpModelDirect pump operations, calibration, maintenanceperformIntent()
Holder PourHolderModelHolder-level operations, delegates to pumpsperformIntent()
Generic PourGenericPourModelCustom endpoints, non-nozzle operations (ice, orchestrated pours)pour()

Intent-Based Pouring Concept

KOS dispense operations use intent-based pouring: rather than specifying exact volumes or implementation details, you specify an intent name that represents an operational function. The backend interprets these intents based on the specific hardware involved.

Common Intent Examples

  • "PRIME" – Prime the pump/line with ingredient
  • "PURGE" – Purge the line
  • "CALIBRATE" – Run calibration operation
  • "FLUSH" – Flush the line with water
  • "HIGH_FLOW_TEST" – High flow rate test
  • "LOW_FLOW_TEST" – Low flow rate test
  • "HOLD_TO_POUR" – User-controlled hold-to-pour

Why Intent-Based?

This abstraction enables:

  • Hardware independence: The UI makes normalized intent calls regardless of pump type
  • Backend flexibility: Different pump types (LFCV, MSV, FCM, micro, etc.) can implement intents differently
  • Complex operations: A single intent can trigger multiple steps (e.g., micro:FLUSH runs 3 operations with a 60-second delay)
  • Simplified UI logic: No need to understand hardware-specific operations or sequences
  • Declarative configuration: Intents are defined by the backend application and can vary by device

Intent Implementation Example

For example, a "PRIME" intent on a micro pump might involve:

<intent name="micro:PRIME">
  <op type="tpour" rate="0.9" duration="7000" diluted="true" errorHandlerId="blockAirDetect" />
  <op type="tpour" rate="0.9" duration="5000" diluted="true" />
</intent>

While the same "PRIME" intent on an MSV pump might be:

<intent name="msv:PRIME">
  <op type="vpour" rate="11.25" volume="50" diluted="true" />
</intent>

The UI simply calls performIntent({ intent: "PRIME" }) and the backend handles the hardware-specific implementation.

Available intents are determined by the device configuration and can vary between dispensers.

NozzleModel Pouring (Consumer UI Pattern)

The NozzleModel provides the primary pour interface for consumer applications where users have selected beverages.

Basic Pour Operation

import { useNozzleContext } from '@kosdev-code/kos-dispense-sdk';
function PourButton() {
  const { model: nozzle } = useNozzleContext();
  const handlePour = async () => {
    if (nozzle?.canPour) {
      try {
        await nozzle.pour();
      } catch (error) {
        console.error('Pour failed:', error);
      }
    }
  };
  return (
    <button
      onClick={handlePour}
      disabled={!nozzle?.canPour}
    >
      Pour
    </button>
  );
}

Note: The @kosFuture() decorator automatically generates a unique tracker ID. Do NOT pass a tracker parameter.

Fixed Pour (Named Sizes)

When the backend supports named pour sizes (e.g., “small”, “medium”, “large”), use fixedPour():

const handleFixedPour = async () => {
  try {
    await nozzle?.fixedPour("medium");
  } catch (error) {
    console.error('Fixed pour failed:', error);
  }
};

The fixedPour() method is used for specific backends that translate size names into fixed volume pours. The main usage pattern is the pour() method for selection-based pours.

Pour State Properties

The NozzleModel exposes several properties for tracking pour state:

// Boolean pour state
const isPouring = nozzle?.isPouring; // true during active pour
// Boolean readiness (selected + available + idle)
const canPour = nozzle?.canPour;
// PourState enum value (IDLE, POUR_STARTING, POURING, POUR_CANCELING)
const currentState = nozzle?.currentState;
// Progress from 0-100 (from Future)
const progress = nozzle?.pourProgress;

Pour State Lifecycle

IDLE → POUR_STARTING → POURING → IDLE
                    ↓
              POUR_CANCELING → IDLE

Canceling Pours

const handleCancel = async () => {
  if (nozzle?.isPouring) {
    await nozzle.cancelPour();
  }
};

Pour Prerequisites

Before calling pour(), ensure:

  1. A pourable is selected via setSelectedPourable()
  2. The selected beverage is available
  3. The nozzle is in IDLE state (canPour === true)
// Set selection before pouring
nozzle?.setSelectedPourable({
  beverage: selectedBeverage,
  additional: selectedFlavors
});
// Check readiness
if (nozzle?.canPour) {
  await nozzle.pour();
}

PumpModel Pouring (Hardware/Maintenance Pattern)

The PumpModel provides direct pump control for maintenance, calibration, and diagnostic operations using intent-based commands.

Performing Intent-Based Operations

import { useAssemblyContext } from '@kosdev-code/kos-dispense-sdk';
function PumpMaintenance({ pumpPath }: { pumpPath: string }) {
  const { model: assembly } = useAssemblyContext();
  const pump = assembly?.pumpContainer.models.find(p => p.path === pumpPath);
  const handlePrimeOperation = async () => {
    try {
      await pump?.performIntent({ intent: "PRIME" });
    } catch (error) {
      console.error('Prime operation failed:', error);
    }
  };
  return (
    <button onClick={handlePrimeOperation} disabled={!pump?.canPour}>
      Prime Pump
    </button>
  );
}

Important: The @kosFuture() decorator automatically generates a unique tracker ID for Future tracking. Do NOT pass the tracker parameter – it’s injected automatically by the decorator.

Intent as String or Object

The performIntent method accepts either a string intent or an IntentAwareProps object:

// IntentAwareProps object (recommended)
await pump?.performIntent({ intent: "PURGE" });
// String intent (convenience)
await pump?.performIntent("PURGE");

Pump Pour State

// Boolean state
const isPouring = pump?.isPouring;
const canPour = pump?.canPour;
// String state for display
const pouringState = pump?.pouringState; // "IDLE", "POURING", etc.
// Current PourState enum
const currentState = pump?.currentState;

Canceling Pump Operations

if (pump?.isPouring) {
  await pump.cancelPour();
}

Cancellation is only valid during POURING or POUR_STARTING states. If the pump is already IDLE, the cancellation is ignored.

Intent Volume Resolution

Before executing an intent, you can query the expected volume:

const volume = pump?.resolveIntentVolume("PRIME");
console.log(`Prime operation will dispense ${volume}ml`);

This is useful for displaying expected volumes in UI before the user initiates an operation.

HolderModel Pouring (Hardware Pattern)

The HolderModel provides holder-level pour operations that delegate to child pumps.

Basic Holder Operations

function HolderMaintenance({ holderPath }: { holderPath: string }) {
  const { model: assembly } = useAssemblyContext();
  const holder = assembly?.holderContainer.holders.find(h => h.path === holderPath);
  const handleFlushOperation = async () => {
    try {
      // Delegates to first child pump by default
      await holder?.performIntent({ intent: "FLUSH" });
    } catch (error) {
      console.error('Flush operation failed:', error);
    }
  };
  return (
    <button onClick={handleFlushOperation} disabled={!holder?.canPour}>
      Flush Line
    </button>
  );
}

Targeting Specific Pump

If a holder has multiple pumps, you can specify which pump to use:

await holder?.performIntent({
  intent: "PRIME",
  pumpPath: "assembly:core:board:macro:pump:A1"
});

Holder Pour State

// Boolean - reflects any child pump pouring
const isPouring = holder?.isPouring;
// Boolean - requires all child pumps can pour AND no shouldDefer
const canPour = holder?.canPour;

The canPour property checks:

  1. All child pumps have canPour === true
  2. No troubles are present that set shouldDefer === true

Canceling All Child Pumps

// Cancels all child pumps
await holder?.cancelPour();

Intent Volume Resolution

Similar to pumps, holders can resolve intent volumes (delegates to child pump):

const volume = holder?.resolveIntentVolume("PRIME");

Generic Pour Operations

The GenericPourModel enables custom pour operations that don’t fit standard nozzle patterns. Use cases include:

  • Ice dispensing: Custom ice dispense endpoints
  • Orchestrated pours: Backend-coordinated multi-nozzle pours
  • Custom hardware: Non-standard dispense points
  • Special operations: Cleaning cycles, custom workflows

Why Generic Pour?

Standard nozzle pouring assumes:

  1. Selection-based workflow (brand → beverage → flavor)
  2. Standard nozzle endpoints (/api/ext/dispense/nozzle/{path}/pour)
  3. Single-nozzle, single-ingredient operations

Generic pour is needed when:

  • Custom endpoints required: Backend needs different API paths
  • Non-nozzle operations: Ice, foam, custom dispensing
  • Backend orchestration: Complex multi-step or multi-device pours
  • Special hardware: Equipment that doesn’t fit standard topology

Creating a Generic Pour Model

import { GenericPour } from '@kosdev-code/kos-dispense-sdk';
// Define service path factory
const iceServicePathFactory = (selectedId: string) =>
  `/api/ice/dispense/${selectedId}`;
// Create model
const icePourModel = GenericPour.instance("ice-pour-001")
  .options({
    servicePathFactory: iceServicePathFactory,
    selectedId: "ice-maker-01",
    pourType: "ice" // Optional pour type for analytics
  })
  .build();

Using Generic Pour Hook

The SDK provides a React hook for generic pours:

import { useGenericPour } from '@kosdev-code/kos-dispense-sdk';
function IceDispenser() {
  const servicePathFactory = (selectedId: string) =>
    `/api/ice/dispense/${selectedId}`;
  const model = useGenericPour(
    "ice-pour-001",
    servicePathFactory,
    "ice-maker-01" // itemId parameter
  );
  const handleIcePour = async () => {
    if (model?.canPour) {
      try {
        await model.pour();
      } catch (error) {
        console.error('Ice pour failed:', error);
      }
    }
  };
  return (
    <button onClick={handleIcePour} disabled={!model?.canPour}>
      Dispense Ice
    </button>
  );
}

Note: Like other pour operations, GenericPourModel.pour() uses @kosFuture() which automatically generates the tracker. Do NOT pass a tracker parameter.

Service Path Factory Pattern

The servicePathFactory is a function that generates the API endpoint based on a selected ID:

// Example: Different endpoints based on ice type
const servicePathFactory = (iceType: string) => {
  switch (iceType) {
    case "cubed":
      return "/api/ice/dispense/cubed";
    case "crushed":
      return "/api/ice/dispense/crushed";
    default:
      return "/api/ice/dispense/default";
  }
};

The selectedId option sets the initial selected ID, which is passed to the factory to determine the service path.

Generic Pour State

// Boolean state
const isPouring = genericPourModel?.isPouring;
const canPour = genericPourModel?.canPour;
// PourState enum
const currentState = genericPourModel?.currentState;
// Progress from Future
const progress = genericPourModel?.pourProgress;

Canceling Generic Pours

await genericPourModel?.cancelPour();

Pour Type for Analytics

The optional pourType parameter allows categorization of pour operations for analytics:

.options({
  servicePathFactory: factory,
  selectedId: "item-01",
  pourType: "ice" // Appears in TOPIC_GENERIC_POUR_STARTED events
})

Generic Pour Topics

The GenericPourModel publishes events for pour lifecycle:

// Pour started
TOPIC_GENERIC_POUR_STARTED: "/kos/generic/pour/started"
// Event payload: { pourTarget: string, type: string }
// Pour progress
TOPIC_GENERIC_POUR_PROGRESS: "/kos/generic/pour/progress"
// Event payload: { pourTarget, progress, status, futureId, ... }
// Pour cancelled
TOPIC_GENERIC_POUR_CANCEL: "/kos/generic/pour/cancel"
// Event payload: { pourTarget: string }

Using usePourTarget Hook

For advanced pour control with gesture handling, use the usePourTarget hook with any PourStrategyAware model (including GenericPourModel):

import { usePourTarget, HoldToPourStrategy } from '@kosdev-code/kos-dispense-sdk';
function AdvancedPourButton({ pourModel }: { pourModel: PourStrategyAware }) {
  const {
    handlers,
    cancel,
    updatePourContext,
    status
  } = usePourTarget({
    pourTarget: pourModel,
    strategy: HoldToPourStrategy, // Optional, defaults to HoldToPourStrategy
    onBeforePour: async (context) => {
      // Validation before pour starts
      // Access data from context to avoid unnecessary callback re-creation
      const selectedItem = context.get("selectedItem");
      const isValid = await validatePourConditions(selectedItem);
      return isValid; // Return false to cancel
    },
    onPour: () => {
      console.log('Pour started');
    },
    onPourCancel: () => {
      console.log('Pour cancelled');
    },
    onPourAbort: () => {
      console.log('Pour aborted');
    }
  });
  return (
    <button {...handlers()}>
      Hold to Pour
    </button>
  );
}

The hook returns gesture handlers that can be spread onto DOM elements and automatically detects touch or mouse events.

Pour Context (CUI Kit)

When using CUI Kit, the pour context is used primarily to prevent unnecessary React re-renders by making data available to validation hooks without recreating callback functions.

How It Works

CUI Kit automatically adds the selected beverage to the pour context, making it accessible in onBeforePour hooks without depending on changing props:

const { pour } = useCuiKit();
// CUI Kit automatically does this internally:
// pour.updatePourContext("selectedBeverage", beverageModel);
// Your validation hook can access context without re-creating
const handleBeforePour = async (context) => {
  const beverage = context.get("selectedBeverage");
  // Perform validation without causing re-renders
  return await validateBeverage(beverage);
};

Adding Custom Context Data

You can add additional context data for your own validation logic:

const { pour } = useCuiKit();
// Add custom validation data
pour.updatePourContext("userAge", user.age);
pour.updatePourContext("requiresVerification", true);
// Access in validation hooks
const onBeforePour = async (context) => {
  const beverage = context.get("selectedBeverage"); // From CUI Kit
  const userAge = context.get("userAge"); // Your custom data
  const requiresVerification = context.get("requiresVerification");
  if (beverage.containsAlcohol && userAge < 21) {
    return false; // Cancel pour
  }
  return true;
};

Why Use Context?

The context pattern prevents this common React pitfall:

// ❌ Bad: Creates new callback on every render when selectedBeverage changes
const onBeforePour = useCallback(async () => {
  return await validateBeverage(selectedBeverage);
}, [selectedBeverage]); // Dependency causes callback re-creation
// ✅ Good: Stable callback, data accessed from context
const onBeforePour = useCallback(async (context) => {
  const beverage = context.get("selectedBeverage");
  return await validateBeverage(beverage);
}, []); // No dependencies, stable callback

See CUI Kit documentation for more details.

Future-Aware Patterns

All pour operations integrate with the KOS Future system for async operation tracking.

Accessing Future State

// Get current Future
const future = nozzle?.future; // or pump.future, genericPourModel.future
if (future) {
  console.log('Progress:', future.progress);
  console.log('Status:', future.status);
  console.log('Remaining time:', future.remainingTimeMs);
  console.log('End state:', future.endState); // Set when complete
}

Future Update Callbacks

Models expose onFutureUpdate callbacks for custom logic:

pump.onFutureUpdate = (future) => {
  console.log(`Operation progress: ${future.progress}%`);
  if (future.endState) {
    console.log('Operation completed:', future.endState);
  }
};

Manual Future Cancellation

const future = nozzle?.future;
await future?.cancelFuture();

However, prefer using model-level cancelPour() methods as they handle state management correctly.

Error Handling

All pour operations should be wrapped in try/catch blocks:

const handlePour = async () => {
  if (!nozzle?.canPour) {
    alert('Cannot pour at this time');
    return;
  }
  try {
    await nozzle.pour();
  } catch (error) {
    console.error('Pour failed:', error);
    // State automatically resets to IDLE on error
    // Show user-friendly error
    alert('Pour operation failed. Please try again.');
  }
};

Common Error Scenarios

  1. Invalid state: Attempting to pour when not in IDLE state
  2. No selection: Calling pour() without a selected pourable (NozzleModel)
  3. Network failure: Device API unreachable
  4. Device trouble: Hardware issues preventing pour
  5. Invalid intent: Intent name not supported by hardware type

Pour Validation

Always check canPour before initiating pours:

// NozzleModel validation
if (nozzle?.canPour) {
  // Selected + available + idle
  await nozzle.pour();
}
// PumpModel validation
if (pump?.canPour) {
  // Idle state
  await pump.performIntent({ intent: "PRIME" });
}
// HolderModel validation
if (holder?.canPour) {
  // All child pumps can pour AND no shouldDefer
  await holder.performIntent({ intent: "FLUSH" });
}

Complete Pour Examples

Consumer UI with Selection

import { useNozzleContext } from '@kosdev-code/kos-dispense-sdk';
import { useState } from 'react';
function BeverageSelection() {
  const { model: nozzle } = useNozzleContext();
  const [selectedBeverage, setSelectedBeverage] = useState(null);
  const beverages = nozzle?.visibleBeverages || [];
  const handleSelectAndPour = async (beverage) => {
    // Set selection
    nozzle?.setSelectedPourable({ beverage, additional: [] });
    setSelectedBeverage(beverage);
    // Wait for selection to update
    await new Promise(resolve => setTimeout(resolve, 100));
    // Pour
    if (nozzle?.canPour) {
      try {
        await nozzle.pour();
      } catch (error) {
        console.error('Pour failed:', error);
      }
    }
  };
  return (
    <div>
      {beverages.map(beverage => (
        <button
          key={beverage.id}
          onClick={() => handleSelectAndPour(beverage)}
        >
          {beverage.data.name}
        </button>
      ))}
      {nozzle?.isPouring && (
        <div>
          <p>Pouring: {nozzle.pourProgress}%</p>
          <button onClick={() => nozzle.cancelPour()}>
            Cancel
          </button>
        </div>
      )}
    </div>
  );
}

Pump Maintenance Operations

import { useAssemblyContext } from '@kosdev-code/kos-dispense-sdk';
import { useState } from 'react';
function PumpMaintenancePanel({ pumpPath }: { pumpPath: string }) {
  const { model: assembly } = useAssemblyContext();
  const pump = assembly?.pumpContainer.models.find(p => p.path === pumpPath);
  const [intent, setIntent] = useState("PRIME");
  const handleOperation = async () => {
    try {
      const expectedVolume = pump?.resolveIntentVolume(intent);
      console.log(`${intent} will dispense ${expectedVolume}ml`);
      await pump?.performIntent({ intent });
    } catch (error) {
      console.error('Operation failed:', error);
    }
  };
  return (
    <div>
      <h3>Pump: {pump?.name}</h3>
      <p>State: {pump?.pouringState}</p>
      <select value={intent} onChange={e => setIntent(e.target.value)}>
        <option value="PRIME">Prime</option>
        <option value="PURGE">Purge</option>
        <option value="CALIBRATE">Calibrate</option>
        <option value="FLUSH">Flush</option>
        <option value="HIGH_FLOW_TEST">High Flow Test</option>
        <option value="LOW_FLOW_TEST">Low Flow Test</option>
      </select>
      <button onClick={handleOperation} disabled={!pump?.canPour}>
        Execute {intent}
      </button>
      {pump?.isPouring && (
        <button onClick={() => pump.cancelPour()}>
          Cancel Operation
        </button>
      )}
    </div>
  );
}

Generic Pour for Ice

import { useGenericPour } from '@kosdev-code/kos-dispense-sdk';
import { useState } from 'react';
function IceDispenser() {
  const servicePathFactory = (iceType: string) =>
    `/api/ice/dispense/${iceType}`;
  const [iceType, setIceType] = useState("cubed");
  const model = useGenericPour(
    "ice-dispenser",
    servicePathFactory,
    iceType
  );
  const handleIcePour = async () => {
    if (model?.canPour) {
      try {
        await model.pour();
      } catch (error) {
        console.error('Ice pour failed:', error);
      }
    }
  };
  return (
    <div>
      <h3>Ice Dispenser</h3>
      <div>
        <button onClick={() => setIceType("cubed")}>Cubed</button>
        <button onClick={() => setIceType("crushed")}>Crushed</button>
      </div>
      <p>Selected: {iceType}</p>
      <button onClick={handleIcePour} disabled={!model?.canPour}>
        Dispense Ice
      </button>
      {model?.isPouring && (
        <div>
          <p>Dispensing: {model.pourProgress}%</p>
          <button onClick={() => model.cancelPour()}>Cancel</button>
        </div>
      )}
    </div>
  );
}

Best Practices

  1. Always validate before pouring: Check canPour before calling pour methods
  2. Use error handling: Wrap all pour operations in try/catch blocks
  3. Provide user feedback: Display pour progress and state to users
  4. Handle cancellation: Provide cancel functionality for better UX
  5. Don’t pass tracker parameter: The @kosFuture() decorator auto-generates it
  6. Check prerequisites: Ensure selections are made before nozzle pours
  7. Respect troubles: Check shouldDefer on holders before allowing pours
  8. Use appropriate pattern: Choose the right pour model for your use case
    • Consumer selection: NozzleModel.pour()
    • Named/fixed sizes: NozzleModel.fixedPour(name) for specific backends
    • Diagnostics/calibration/maintenance: PumpModel.performIntent()
    • Custom endpoints/orchestration: GenericPourModel.pour()
  9. Understand intent purpose: Intents represent operational functions (PRIME, PURGE, etc.), not pour sizes
  10. Device-specific intents: Available intents are defined by backend configuration and may vary
  11. Intent complexity: Single intents can trigger multi-step operations automatically
  12. Use pour context wisely: Primary purpose is preventing re-renders in validation hooks, not general analytics

Next Steps

Related Documentation