Dispensers#
Custom dispensers let you control any pump or valve hardware that CocktailBerry does not support out of the box.
Each dispenser extension is a single Python file placed in the addons/dispensers/ folder.
Once added, the new dispenser type appears in the pump configuration dropdown alongside the built-in DC and Stepper types.
For dispensers, the base classes are:
ExtensionConfiginherits fromBasePumpConfig(src.config.config_types)Implementationinherits fromBaseDispenser(src.machine.dispensers.base)
Getting Started#
Use the CLI
Create a skeleton file with the CLI command:
This creates a ready-to-fill file in addons/dispensers/your_dispenser_name.py.
Shared vs Custom Config Fields#
Every dispenser automatically gets the following shared fields injected — you do not need to define them:
pump_type— dropdown to select the dispenser typevolume_flow— flow rate in ml/stube_volume— tube volume in mlconsumption_estimation— time or weight basedcarriage_position— position 0–100%
Only define your extra fields in CONFIG_FIELDS.
These will appear in the configuration UI between the pump type dropdown and the shared fields.
While you might not need all these shared fields for every dispenser, they are commonly used and provide a consistent configuration experience across dispenser types.
ExtensionConfig#
Your ExtensionConfig must inherit from BasePumpConfig.
Define any extra attributes your dispenser needs and make sure to call super().__init__() with the shared fields.
The to_config() method must serialize all fields (call super().to_config() and update with your extras).
Accept **kwargs
Your __init__ should accept **kwargs to be forward-compatible with future shared fields.
Implementation#
Your Implementation class must inherit from BaseDispenser and implement these methods:
| Method | Required | Description |
|---|---|---|
_dispense_steps(amount_ml, pump_speed) |
yes | Generator that yields consumption values. Use try/finally for hardware cleanup. See details below. |
_before_dispense(ctx) |
no | Called once before dispensing starts. Override to switch direction when ctx.revert is True. Default is a no-op. |
_after_dispense(ctx) |
no | Called once after dispensing finishes (including cancellation). Override to restore direction when ctx.revert is True. Default is a no-op. |
stop() |
no | Emergency stop. Default sets the internal stop event. Override and call super().stop() if you need additional hardware cleanup (e.g. close pin). |
cleanup() |
no | Release hardware resources at shutdown. Default does nothing. |
The constructor receives slot (pump position), config (your ExtensionConfig instance), hardware (HardwareContext — provides access to pin controller, scale, LED controller, carriage, and extra dict of hardware extension instances).
Initialize hardware resources directly in __init__().
How _dispense_steps() Works#
The base class provides a concrete dispense() method that drives your generator automatically:
- Clears the stop event
- Tares the scale (if connected)
- Calls
_before_dispense(ctx)(direction setup, wherectxis aDispenseContext) - Iterates your
_dispense_steps()generator - Checks for cancellation between each yield
- Calls progress callbacks on each yielded value
- Calls
_after_dispense(ctx)(direction teardown)
Your generator just needs to: activate hardware, yield consumption updates in a loop, and deactivate hardware in a finally block. The finally block runs on both normal completion and cancellation (the generator is automatically closed when the stop event fires).
Per-Dispenser Reversion (Dispenser Controlled mode)#
When the machine is configured with MAKER_PUMP_REVERSION_CONFIG set to Dispenser Controlled, there is no global relay pin — each dispenser is responsible for reversing its own motor direction if requested during cleaning.
To support this, override _before_dispense and _after_dispense. Both receive a DispenseContext object — currently it carries revert: bool, and future versions may add more fields with defaults without breaking your extension:
from src.machine.dispensers.base import DispenseContext
def _before_dispense(self, ctx: DispenseContext) -> None:
if ctx.revert:
# Switch motor direction, e.g. flip H-bridge pins
...
def _after_dispense(self, ctx: DispenseContext) -> None:
if ctx.revert:
# Restore normal direction
...
If your dispenser does not override these hooks, it silently skips reversion — no error is raised.
Inherited Attributes & Helpers#
BaseDispenser provides several attributes and methods you can use in your implementation — no need to define them yourself:
| Attribute / Method | Description |
|---|---|
self.slot |
Pump slot number (int), set from constructor |
self.config |
Your ExtensionConfig instance |
self.volume_flow |
Configured flow rate in ml/s (from config) |
self.carriage_position |
Carriage position 0–100 (from config) |
self.hardware |
HardwareContext — access to pin_controller, led_controller, scale, carriage, and extra (see Hardware Context Extensions) |
self._scale |
ScaleInterface or None — the connected scale, if any and dispensing is based on weight |
self._get_consumption(estimate) |
Returns the scale reading in ml if a scale is present, otherwise returns the passed time/step-based estimate |
self.needs_exclusive |
Property, True when a scale is attached. Used by the scheduler to run this dispenser exclusively (not in parallel with others) |
Using the Scale#
Scale taring is handled automatically by the base dispense() method. In your _dispense_steps() generator, just use _get_consumption() to transparently read either the scale or a time-based estimate:
# In your _dispense_steps loop, use _get_consumption instead of a raw time estimate:
time_estimate = elapsed * effective_flow
consumption = self._get_consumption(time_estimate)
# If a scale is present, this returns the actual weight reading.
# Otherwise it returns your time_estimate as a fallback.
Lifecycle#
Dispenser extensions follow this lifecycle:
- Discovery & variant registration — Extensions are discovered and registered as variants of
PUMP_CONFIGbefore config is read. - Config load — The GUI can now show and edit the dispenser extension fields.
- Construction — During
init_machine(), after the scale and carriage are created,Implementation(slot, config, hardware)is called once per pump slot. You receive the fully assembledHardwareContext(pin controller, LEDs, scale, carriage,extra). - Runtime use — The scheduler calls
dispense(amount_ml, pump_speed, revert, callback)on each slot. The base class builds aDispenseContext, calls_before_dispense(ctx), drives your_dispense_steps()generator, dispatches progress updates, then calls_after_dispense(ctx).stop()may be called from another thread to cancel. cleanup()— Called at shutdown to release hardware resources.
Full Example#
Below is a complete example of a simple dummy dispenser that simulates dispensing via sleep:
from __future__ import annotations
import time
from collections.abc import Generator
from typing import TYPE_CHECKING, Any
from src import ConsumptionEstimationType
from src.config.config_types import BasePumpConfig, ConfigInterface, StringType # (1)!
from src.logger_handler import LoggerHandler
from src.machine.dispensers.base import BaseDispenser # (2)!
if TYPE_CHECKING:
from src.machine.hardware import HardwareContext
EXTENSION_NAME = "Dummy" # (3)!
_logger = LoggerHandler("DummyDispenser")
class ExtensionConfig(BasePumpConfig): # (4)!
"""Dummy dispenser config with a custom label field."""
label: str
def __init__(
self,
label: str = "dummy",
pump_type: str = EXTENSION_NAME,
volume_flow: float = 30.0,
tube_volume: int = 0,
consumption_estimation: ConsumptionEstimationType = "time",
carriage_position: int = 0,
**kwargs: Any, # (5)!
) -> None:
super().__init__(
pump_type=pump_type,
volume_flow=volume_flow,
tube_volume=tube_volume,
consumption_estimation=consumption_estimation,
carriage_position=carriage_position,
)
self.label = label
def to_config(self) -> dict[str, Any]: # (6)!
config = super().to_config()
config.update({"label": self.label})
return config
CONFIG_FIELDS: dict[str, ConfigInterface] = { # (7)!
"label": StringType(default="dummy"),
}
class Implementation(BaseDispenser): # (8)!
"""Dummy dispenser that simulates dispensing via sleep."""
def __init__(
self, slot: int, config: ExtensionConfig, hardware: HardwareContext,
) -> None:
super().__init__(slot, config, hardware)
self.label = config.label
# Initialize your hardware here
_logger.info(f"Dummy dispenser '{self.label}' slot {self.slot} initialized")
def _dispense_steps( # (9)!
self, amount_ml: float, pump_speed: int
) -> Generator[float, None, None]:
effective_flow = self.volume_flow * pump_speed / 100
step_interval = 0.1
elapsed = 0.0
_logger.info(
f"Dummy '{self.label}' slot {self.slot}: "
f"dispensing {amount_ml:.1f} ml"
)
consumption = 0.0
try:
# >>> Activate your hardware here <<<
while True: # (10)!
time.sleep(step_interval)
elapsed += step_interval
time_estimate = min(elapsed * effective_flow, amount_ml)
consumption = self._get_consumption(time_estimate)
yield consumption # (11)!
if consumption >= amount_ml:
return
finally:
# >>> Deactivate your hardware here <<< (12)!
_logger.info(
f"Dummy '{self.label}' slot {self.slot}: "
f"done, dispensed {consumption:.1f} ml"
)
def cleanup(self) -> None: # (13)!
_logger.info(
f"Dummy dispenser '{self.label}' slot {self.slot} cleaned up"
)
- Import
BasePumpConfigfor your config class and any config field types you need (hereStringType). - Import
BaseDispenser— the base class for all dispensers. - Unique name that appears in the pump type dropdown. Must match the
pump_typedefault in yourExtensionConfig. - Your config class must inherit from
BasePumpConfig. Add any extra attributes your dispenser needs. - Always accept
**kwargsto stay forward-compatible with future shared fields. - Serialize all fields — call
super().to_config()first, then update with your extra fields. - Only define your extra config fields here. Shared fields (
volume_flow,tube_volume, etc.) and thepump_typedropdown are auto-injected. - Your dispenser implementation must inherit from
BaseDispenser. - Generator that yields consumption values. The base
dispense()method handles stop events, scale taring, and progress callbacks — you just yield. - Use a simple
while Trueloop. No need to checkself._stop_event— the base class checks it between yields and closes the generator on cancellation. - Yield the current consumption. The base class passes this to the scheduler's progress callback. Important to do this regularly (e.g. every 0.1s) so the UI can update and cancellations are responsive.
- The
finallyblock runs on both normal completion and cancellation — use it for hardware cleanup (e.g. closing relay pins, stopping motors). - Called at program shutdown to release hardware resources.