Scales#
Custom scales let you drive any load-cell amplifier or weighing hardware that CocktailBerry does not support out of the box.
Each scale extension is a single Python file placed in the addons/scales/ folder.
Once added, the new scale type appears in the scale configuration dropdown alongside the built-in HX711 and NAU7802 drivers.
Unlike dispensers, a single scale is created per machine (one SCALE_CONFIG entry).
It is wired into the HardwareContext and shared across all weight-based dispensers.
Any dispenser referencing the scale is automatically scheduled exclusively (no parallel dispensing) so readings stay consistent.
During preparation, the dispenser stops slightly before the target weight and waits for the reading to settle, so liquid still falling does not overshoot the requested amount.
For scales, the base classes are:
ExtensionConfiginherits fromBaseScaleConfig(src.config.config_types)Implementationinherits fromScaleInterface(src.machine.scale.base)
Getting Started#
Use the CLI
Create a skeleton file with the CLI command:
This creates a ready-to-fill file in addons/scales/your_scale_name.py.
Shared vs Custom Config Fields#
Every scale automatically gets the following shared fields injected ā you do not need to define them:
scale_typeā dropdown to select the scale driverenabledā whether the scale is activecalibration_factorā raw-units-per-gram, written by the calibration routinezero_raw_offsetā raw ADC reading that corresponds to an empty scale (also persisted during calibration)
Only define your extra fields in CONFIG_FIELDS (e.g. data/clock pins, I²C address, spi bus, ā¦).
These will appear in the configuration UI between the scale type dropdown and the shared fields.
ExtensionConfig#
Your ExtensionConfig must inherit from BaseScaleConfig.
Define any extra attributes your scale 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 ScaleInterface and implement the following abstract methods:
| Method | Required | Description |
|---|---|---|
tare(samples) |
yes | Capture the current raw reading as the dynamic tare offset. Subsequent read_grams() calls are relative to this point. Returns the raw value. |
read_grams() |
yes | Return the weight in grams relative to the last tare() call. |
read_raw(samples) |
yes | Return the averaged raw ADC reading (no offset, no calibration). Used during calibration. |
cleanup() |
yes | Release any hardware resources held by the scale. |
get_gross_grams() |
yes | Return the absolute weight in grams relative to _zero_raw_offset (the one-time empty-scale calibration). Used for glass detection etc. |
The constructor receives config (your ExtensionConfig instance) and hardware (HardwareContext ā provides access to pin controller, LED controller, and extra dict of hardware extension instances).
Built-in scales may ignore hardware, extensions may use it to access pins or shared hardware.
Inherited Attributes & Helpers#
ScaleInterface provides the following for your implementation:
| Attribute / Method | Description |
|---|---|
self.config |
Your ExtensionConfig instance |
self.hardware |
HardwareContext ā access to pin_controller, led_controller, and extra (see Hardware Context Extensions) |
self._calibration_factor |
Raw-units-per-gram, loaded from config. Use it in read_grams() / get_gross_grams(). |
self._zero_raw_offset |
Raw ADC reading corresponding to 0 g on an empty scale. Written during one-time calibration. |
calibrate_with_known_weight(weight_g, zero_raw_offset, samples=10) |
Default implementation: computes the scale factor from a known weight and persists it. Override if your scale needs a custom routine. |
set_calibration_factor(factor) / set_zero_raw_offset(offset) |
Setters used by the calibration flow. Override if you need additional side-effects (e.g. writing to EEPROM on the amplifier). |
Lifecycle#
Scale extensions follow this lifecycle:
- Discovery & variant registration ā Extensions are discovered and registered as variants of
SCALE_CONFIGbefore config is read. - Config load ā The GUI can now show and edit the scale extension fields.
- Construction ā During
init_machine(), after hardware extensions are created,Implementation(config, hardware)is called. You receive theHardwareContext(pin controller, LEDs,extra). The instance is wired into the context and shared across all weight-based dispensers. - Runtime use ā Dispensers and the calibration UI call
tare(),read_grams(),read_raw(), andget_gross_grams()as needed.calibrate_with_known_weight()is invoked from the calibration flow. cleanup()ā Called at shutdown to release hardware resources (GPIO, SPI, I²C).
Full Example#
Below is a complete example of a fake software scale that reports a constant weight, useful as a template or for dry-running without hardware:
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from src.config.config_types import BaseScaleConfig, ConfigInterface, FloatType # (1)!
from src.config.validators import build_number_limiter
from src.logger_handler import LoggerHandler
from src.machine.scale.base import ScaleInterface # (2)!
if TYPE_CHECKING:
from src.machine.hardware import HardwareContext
EXTENSION_NAME = "FakeScale" # (3)!
_logger = LoggerHandler("FakeScale")
class ExtensionConfig(BaseScaleConfig): # (4)!
"""Fake scale config with a constant reading."""
constant_grams: float
def __init__(
self,
constant_grams: float = 100.0,
scale_type: str = EXTENSION_NAME,
enabled: bool = False,
calibration_factor: float = 1.0,
zero_raw_offset: int = 0,
**kwargs: Any, # (5)!
) -> None:
super().__init__(
scale_type=scale_type,
enabled=enabled,
calibration_factor=calibration_factor,
zero_raw_offset=zero_raw_offset,
)
self.constant_grams = constant_grams
def to_config(self) -> dict[str, Any]: # (6)!
config = super().to_config()
config.update({"constant_grams": self.constant_grams})
return config
CONFIG_FIELDS: dict[str, ConfigInterface] = { # (7)!
"constant_grams": FloatType([build_number_limiter(0, 10000)], suffix="g"),
}
class Implementation(ScaleInterface): # (8)!
"""Fake scale that always reports ``constant_grams``."""
def __init__(
self, config: ExtensionConfig, hardware: HardwareContext,
) -> None:
super().__init__(config, hardware)
self._constant = config.constant_grams
self._offset = 0
_logger.info(f"Fake scale initialized with {self._constant}g")
def _sample_raw(self, samples: int) -> int: # (9)!
return int(self._constant * self._calibration_factor) + self._zero_raw_offset
def tare(self, samples: int = 3) -> int:
self._offset = self._sample_raw(samples)
return self._offset
def read_grams(self) -> float:
return (self._sample_raw(1) - self._offset) / self._calibration_factor
def read_raw(self, samples: int = 1) -> int:
return self._sample_raw(samples)
def get_gross_grams(self) -> float:
return (self._sample_raw(1) - self._zero_raw_offset) / self._calibration_factor
def cleanup(self) -> None: # (10)!
_logger.info("Fake scale cleaned up")
- Import
BaseScaleConfigfor your config class and any config field types you need (hereFloatType). - Import
ScaleInterfaceā the abstract base class for all scales. - Unique name that appears in the scale type dropdown. Must match the
scale_typedefault in yourExtensionConfig. - Your config class must inherit from
BaseScaleConfig. Add any extra attributes your scale 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 (
enabled,calibration_factor,zero_raw_offset) and thescale_typedropdown are auto-injected. - Your scale implementation must inherit from
ScaleInterface. - Helper to centralise raw-sample logic. Replace with real hardware reads (GPIO bit-bang, I²C/SPI transaction, serial protocol ā¦).
- Called at program shutdown ā release GPIO, close buses, disable amplifier.