diff --git a/src/config/config_manager.py b/src/config/config_manager.py index 51b1b37a..ab118c15 100644 --- a/src/config/config_manager.py +++ b/src/config/config_manager.py @@ -40,7 +40,7 @@ WS281xLedConfig, ) from src.config.errors import ConfigError -from src.config.validators import build_number_limiter, validate_max_length, valide_no_identical_active_i2c_devices +from src.config.validators import build_distinct_validator, build_number_limiter, validate_max_length from src.filepath import CUSTOM_CONFIG_FILE from src.logger_handler import LoggerHandler from src.models import CocktailStatus @@ -191,6 +191,7 @@ def __init__(self) -> None: DictType( { "pin_type": ChooseOptions.pin, + "board_number": IntType([build_number_limiter(1, 99)], prefix="#", default=1), "pin": IntType([build_number_limiter(0)], prefix="Pin:"), "volume_flow": FloatType([build_number_limiter(0.1, 1000)], suffix="ml/s"), "tube_volume": IntType([build_number_limiter(0, 100)], suffix="ml"), @@ -198,11 +199,13 @@ def __init__(self) -> None: PumpConfig, ), lambda: self.choose_bottle_number(ignore_limits=True), + [build_distinct_validator(["pin_type", "board_number", "pin"])], ), "I2C_CONFIG": ListType( DictType( { "device_type": ChooseOptions.i2c, + "board_number": IntType([build_number_limiter(1, 99)], prefix="#", default=1), "enabled": BoolType(check_name="Enabled", default=True), "address_int": IntType(prefix="0x", default=20), "inverted": BoolType(check_name="Inverted"), @@ -210,7 +213,7 @@ def __init__(self) -> None: I2CExpanderConfig, ), 0, - [valide_no_identical_active_i2c_devices], + [build_distinct_validator(["device_type", "board_number"])], ), "MAKER_NAME": StringType([validate_max_length]), "MAKER_NUMBER_BOTTLES": IntType([build_number_limiter(1, 999)]), @@ -221,8 +224,9 @@ def __init__(self) -> None: "MAKER_PUMP_REVERSION_CONFIG": DictType( { "use_reversion": BoolType(check_name="active", default=False), - "pin": IntType([build_number_limiter(0)], default=0, prefix="Pin:"), "pin_type": ChooseOptions.pin, + "board_number": IntType([build_number_limiter(1, 99)], prefix="#", default=1), + "pin": IntType([build_number_limiter(0)], default=0, prefix="Pin:"), "inverted": BoolType(check_name="Inverted", default=False), }, ReversionConfig, @@ -239,6 +243,7 @@ def __init__(self) -> None: DictType( { "pin_type": ChooseOptions.pin, + "board_number": IntType([build_number_limiter(1, 99)], prefix="#", default=1), "pin": IntType([build_number_limiter(0)], prefix="Pin:"), "default_on": BoolType(check_name="Default On"), "preparation_state": ChooseOptions.leds, @@ -415,21 +420,22 @@ def _validate_cross_config(self, validate: bool) -> None: - Checks that WAITER_MODE is not enabled alongside CocktailBerry NFC payment. - Check that some nfc is active when payment/waiter mode is active. """ - # Collect all I2C pin types used in PUMP_CONFIG (excluding GPIO) - required_i2c_types: set[str] = set() + # Collect all I2C (pin_type, board_number) pairs used in PUMP_CONFIG (excluding GPIO) + required_i2c_boards: set[tuple[str, int]] = set() for pump in self.PUMP_CONFIG: if pump.pin_type != "GPIO": - required_i2c_types.add(pump.pin_type) + required_i2c_boards.add((pump.pin_type, pump.board_number)) - # Get enabled I2C device types from I2C_CONFIG - enabled_i2c_types = {cfg.device_type for cfg in self.I2C_CONFIG if cfg.enabled} + # Get enabled I2C (device_type, board_number) pairs from I2C_CONFIG + enabled_i2c_boards = {(cfg.device_type, cfg.board_number) for cfg in self.I2C_CONFIG if cfg.enabled} - # Check that all required I2C types have enabled devices - missing_types = required_i2c_types - enabled_i2c_types - if missing_types: + # Check that all required I2C boards have enabled devices + missing_boards = required_i2c_boards - enabled_i2c_boards + if missing_boards: + readable = ", ".join(f"{t} board {n}" for t, n in missing_boards) error_msg = ( - f"PUMP_CONFIG uses I2C pin types {', '.join(missing_types)} but I2C_CONFIG " - "has no enabled devices of these types. Add enabled entries to I2C_CONFIG." + f"PUMP_CONFIG uses I2C boards {readable} but I2C_CONFIG " + "has no enabled devices for them. Add enabled entries to I2C_CONFIG." ) _logger.error(f"Config Error: {error_msg}") if validate: diff --git a/src/config/config_types.py b/src/config/config_types.py index e2925a07..2edfab3c 100644 --- a/src/config/config_types.py +++ b/src/config/config_types.py @@ -318,16 +318,17 @@ def get_default_config_class(self) -> ConfigClassT: @dataclass(frozen=True) class PinId: - """Unique identifier for a pin, combining the pin type and pin number.""" + """Unique identifier for a pin, combining the pin type, board number, and pin number.""" pin_type: SupportedPinControlType + board_number: int pin: int def __str__(self) -> str: - return f"{self.pin_type}-{self.pin}" + return f"{self.pin_type}-{self.board_number}-{self.pin}" def __repr__(self) -> str: - return f"{self.pin_type}-{self.pin}" + return f"{self.pin_type}-{self.board_number}-{self.pin}" class PumpConfig(ConfigClass): @@ -339,20 +340,23 @@ def __init__( volume_flow: float, tube_volume: int, pin_type: SupportedPinControlType = "GPIO", + board_number: int = 1, ) -> None: self.pin_type = pin_type self.pin = pin self.volume_flow = volume_flow self.tube_volume = tube_volume + self.board_number = board_number @property def pin_id(self) -> PinId: - """Build PinId from this config's pin_type and pin.""" - return PinId(self.pin_type, self.pin) + """Build PinId from this config's pin_type, board_number and pin.""" + return PinId(self.pin_type, self.board_number, self.pin) def to_config(self) -> dict[str, int | float | SupportedPinControlType]: return { "pin_type": self.pin_type, + "board_number": self.board_number, "pin": self.pin, "volume_flow": self.volume_flow, "tube_volume": self.tube_volume, @@ -364,6 +368,7 @@ class I2CExpanderConfig(ConfigClass): Shared by MCP23017 (16 pins, 0-15), PCF8574 (8 pins, 0-7), and PCA9535 (16 pins, 0-15). Default I2C address is 0x20, configurable to 0x20-0x27. + Multiple boards of the same type are distinguished by board_number. """ device_type: I2CExpanderType @@ -374,11 +379,13 @@ def __init__( enabled: bool, address_int: int, inverted: bool, + board_number: int = 1, ) -> None: self.device_type = device_type self.enabled = enabled self.address_int = address_int self.inverted = inverted + self.board_number = board_number @property def address_hex(self) -> int: @@ -387,6 +394,7 @@ def address_hex(self) -> int: def to_config(self) -> dict[str, bool | int | str]: return { "device_type": self.device_type, + "board_number": self.board_number, "enabled": self.enabled, "address_int": self.address_int, "inverted": self.inverted, @@ -404,20 +412,23 @@ def __init__( pin: int, inverted: bool, pin_type: SupportedPinControlType = "GPIO", + board_number: int = 1, ) -> None: self.use_reversion = use_reversion self.pin = pin self.pin_type = pin_type self.inverted = inverted + self.board_number = board_number @property def pin_id(self) -> PinId: - """Build PinId from this config's pin_type and pin.""" - return PinId(self.pin_type, self.pin) + """Build PinId from this config's pin_type, board_number and pin.""" + return PinId(self.pin_type, self.board_number, self.pin) def to_config(self) -> dict[str, Any]: return { "pin_type": self.pin_type, + "board_number": self.board_number, "pin": self.pin, "use_reversion": self.use_reversion, "inverted": self.inverted, @@ -435,20 +446,23 @@ def __init__( default_on: bool, preparation_state: SupportedLedStatesType, pin_type: SupportedPinControlType = "GPIO", + board_number: int = 1, ) -> None: self.pin = pin self.default_on = default_on self.preparation_state = preparation_state self.pin_type = pin_type + self.board_number = board_number @property def pin_id(self) -> PinId: - """Build PinId from this config's pin_type and pin.""" - return PinId(self.pin_type, self.pin) + """Build PinId from this config's pin_type, board_number and pin.""" + return PinId(self.pin_type, self.board_number, self.pin) def to_config(self) -> dict[str, Any]: return { "pin_type": self.pin_type, + "board_number": self.board_number, "pin": self.pin, "default_on": self.default_on, "preparation_state": self.preparation_state, @@ -477,7 +491,7 @@ def __init__( @property def pin_id(self) -> PinId: """Build PinId from this config's pin_type and pin.""" - return PinId("GPIO", self.pin) + return PinId("GPIO", 1, self.pin) def to_config(self) -> dict[str, Any]: return { diff --git a/src/config/validators.py b/src/config/validators.py index 4b736cf0..cd9655cd 100644 --- a/src/config/validators.py +++ b/src/config/validators.py @@ -22,12 +22,22 @@ def limit_number(configname: str, data: float) -> None: return limit_number -def valide_no_identical_active_i2c_devices(configname: str, data: list[dict]) -> None: - """Validate that no I2C device type is enabled more than once.""" - active_types = [d["device_type"] for d in data if d["enabled"]] - duplicates = {t for t in active_types if active_types.count(t) > 1} - if duplicates: - raise ConfigError( - f"{configname} has duplicate enabled device types: " - f"{', '.join(duplicates)}. Each type can only be enabled once." - ) +def build_distinct_validator(keys: list[str]) -> Callable[[str, list[dict]], None]: + """Build a validator that checks list items have distinct values across the given keys. + + Each combination of values for the specified keys must be unique across all items in the list. + """ + + def validate_distinct(configname: str, data: list[dict]) -> None: + seen: list[tuple] = [] + for item in data: + key_tuple = tuple(item.get(k) for k in keys) + if key_tuple in seen: + readable = ", ".join(f"{k}={item.get(k)}" for k in keys) + raise ConfigError( + f"{configname} has duplicate entries for ({readable}). " + f"Each combination of {', '.join(keys)} must be unique." + ) + seen.append(key_tuple) + + return validate_distinct diff --git a/src/language.yaml b/src/language.yaml index 209e387e..281cb874 100644 --- a/src/language.yaml +++ b/src/language.yaml @@ -724,11 +724,11 @@ ui: en: 'Disable all other tabs than the maker one; You will only be prompted at program start if you want to go to config window with master password' de: 'Alle anderen Tabs als den Maker-Tab deaktivieren; Du wirst nur beim Programmstart gefragt, ob du mit dem Master-Passwort zum Konfigurationsfenster gehen möchtest' PUMP_CONFIG: - en: 'Setting for each pump: Pin connected to the pump, volume flow in ml/s, tube volume to the pump in ml (used to pump given volume after a bottle change). I2C start at 0-7 or 0-15. If using I2C, the pin is the pin on the I2C relais (0-7 or 0-15) and not the GPIO pin.' - de: 'Einstellung für jede Pumpe: Pin verbunden mit der Pumpe, Volumenstrom in ml/s, Schlauchvolumen zur Pumpe in ml (wird verwendet, um das gegebene Volumen nach einem Flaschenwechsel zu pumpen). I2C beginnt bei 0-7 oder 0-15. Bei Verwendung von I2C ist der Pin der Pin auf dem I2C-Relais (0-7 oder 0-15) und nicht der GPIO-Pin.' + en: 'Setting for each pump: Pin type (GPIO or I2C board), board number (# only relevant for I2C, to distinguish multiple boards of the same type), pin connected to the pump, volume flow in ml/s, tube volume to the pump in ml (used to pump given volume after a bottle change). I2C pins start at 0-7 or 0-15 depending on the board type.' + de: 'Einstellung für jede Pumpe: Pin-Typ (GPIO oder I2C-Board), Board-Nummer (# nur relevant für I2C, um mehrere Boards des gleichen Typs zu unterscheiden), Pin verbunden mit der Pumpe, Volumenstrom in ml/s, Schlauchvolumen zur Pumpe in ml (wird verwendet, um das gegebene Volumen nach einem Flaschenwechsel zu pumpen). I2C-Pins beginnen bei 0-7 oder 0-15 je nach Board-Typ.' I2C_CONFIG: - en: 'I2C GPIO expander configuration. Enable if using I2C Relais for pump control. Set the I2C address (default 0x20). See supported boards over dropdown.' - de: 'I2C GPIO-Expander Konfiguration. Aktivieren wenn I2C Relais für Pumpensteuerung verwendet wird. I2C-Adresse setzen (Standard 0x20). Unterstützte Boards über Dropdown auswählen.' + en: 'I2C GPIO expander configuration. Select the board type, board number (# to distinguish multiple boards of the same type), enable/disable, I2C address (default 0x20) and signal inversion. Multiple boards of the same type are supported with different board numbers.' + de: 'I2C GPIO-Expander Konfiguration. Board-Typ, Board-Nummer (# um mehrere Boards des gleichen Typs zu unterscheiden), Aktivierung, I2C-Adresse (Standard 0x20) und Signalinvertierung auswählen. Mehrere Boards des gleichen Typs werden über verschiedene Board-Nummern unterstützt.' MAKER_NAME: en: 'Name of your CocktailBerry machine' de: 'Name von deiner CocktailBerry Maschine' @@ -748,8 +748,8 @@ ui: en: 'Value to scale alcoholic ingredients in each recipe. Use this to decrease/increase the alcohol level of all cocktails. Default is 100%' de: 'Skalierungsfaktor für alkoholische Zutaten in jedem Rezept. Verwende diesen, um den Alkoholgehalt aller Cocktails zu verringern/erhöhen. Standard ist 100%' MAKER_PUMP_REVERSION_CONFIG: - en: 'Configuration for pump reversion, needed for some setups to be able to revert the pump direction.' - de: 'Konfiguration für Pumpen Umkehrung, benötigt von einigen Setups, um die Pumpenrichtung ändern zu können.' + en: 'Configuration for pump reversion: pin type, board number (# only relevant for I2C), pin and signal inversion. Needed for some setups to be able to revert the pump direction.' + de: 'Konfiguration für Pumpen Umkehrung: Pin-Typ, Board-Nummer (# nur relevant für I2C), Pin und Signalinvertierung. Benötigt von einigen Setups, um die Pumpenrichtung ändern zu können.' MAKER_SEARCH_UPDATES: en: 'Search for updates, if available internet connection' de: 'Sucht nach Updates, wenn Internetverbindung besteht' @@ -778,8 +778,8 @@ ui: en: 'Adds a random cocktail tile to the maker tab that picks a surprise cocktail on prepare' de: 'Fügt eine Zufalls-Cocktail Kachel zum Maker Tab hinzu, die beim Zubereiten einen Überraschungscocktail wählt' LED_NORMAL: - en: 'List with config for each normal (non-addressable) LED: pin, default on state, preparation state (on/off/effects)' - de: 'Liste mit Einstellung für jeden normalen (nicht ansteuerbaren) LED: Pin, Standard an-Zustand, Zustand bei Zubereitung (an/aus/Effekte)' + en: 'List with config for each normal (non-addressable) LED: pin type, board number (# only relevant for I2C), pin, default on state, preparation state (on/off/effects)' + de: 'Liste mit Einstellung für jeden normalen (nicht ansteuerbaren) LED: Pin-Typ, Board-Nummer (# nur relevant für I2C), Pin, Standard an-Zustand, Zustand bei Zubereitung (an/aus/Effekte)' LED_WSLED: en: 'List with config for each WS281x (addressable) LED: pin, brightness, LED count, number of daisy chained rings, default on state, preparation state (on/off/effects). Only supports GPIO.' de: 'Liste mit Einstellung für jeden WS281x (ansteuerbaren) LED: Pin, Helligkeit, LED Anzahl, Anzahl der in Serie verbundenen Ringe, Standard an-Zustand, Zustand bei Zubereitung (an/aus/Effekte). Unterstützt nur GPIO.' diff --git a/src/machine/i2c/i2c_expander_factory.py b/src/machine/i2c/i2c_expander_factory.py index 96dcf8f7..7b1f646f 100644 --- a/src/machine/i2c/i2c_expander_factory.py +++ b/src/machine/i2c/i2c_expander_factory.py @@ -25,15 +25,16 @@ class I2CExpanderFactory: """Factory for managing I2C expander devices and creating GPIO controllers. Takes a list of I2CExpanderConfig and manages device instances internally. + Supports multiple boards of the same type, distinguished by board_number. Provides create_i2c_gpio() to create GPIO controllers for specific device types. """ def __init__(self, configs: list[I2CExpanderConfig]) -> None: self._configs = configs self._i2c = None - self._mcp23017: MCP23017 | None = None - self._pcf8574: PCF8574 | None = None - self._pca9535: PCA9535 | None = None + self._mcp23017: dict[int, MCP23017] = {} + self._pcf8574: dict[int, PCF8574] = {} + self._pca9535: dict[int, PCA9535] = {} self._initialize_devices() def _initialize_devices(self) -> None: @@ -52,27 +53,25 @@ def _initialize_devices(self) -> None: # Initialize each enabled device for config in enabled_configs: device_type = config.device_type + board_number = config.board_number match device_type: case "MCP23017": - if self._mcp23017 is not None: - _logger.warning("Duplicate MCP23017 config found, using first one") - continue - self._mcp23017 = get_mcp23017(config, self._i2c) + device = get_mcp23017(config, self._i2c) + if device is not None: + self._mcp23017[board_number] = device case "PCF8574": - if self._pcf8574 is not None: - _logger.warning("Duplicate PCF8574 config found, using first one") - continue - self._pcf8574 = get_pcf8574(config, self._i2c) + device = get_pcf8574(config, self._i2c) + if device is not None: + self._pcf8574[board_number] = device case "PCA9535": - if self._pca9535 is not None: - _logger.warning("Duplicate PCA9535 config found, using first one") - continue - self._pca9535 = get_pca9535(config, self._i2c) + device = get_pca9535(config, self._i2c) + if device is not None: + self._pca9535[board_number] = device - def get_config_for_type(self, device_type: I2CExpanderType) -> I2CExpanderConfig | None: - """Get the config for a specific device type.""" + def get_config_for_type(self, device_type: I2CExpanderType, board_number: int = 1) -> I2CExpanderConfig | None: + """Get the config for a specific device type and board number.""" for config in self._configs: - if config.device_type == device_type and config.enabled: + if config.device_type == device_type and config.board_number == board_number and config.enabled: return config return None @@ -80,35 +79,40 @@ def create_i2c_gpio( self, device_type: I2CExpanderType, pin: int, + board_number: int = 1, invert_override: bool | None = None, ) -> SinglePinController: - """Create a GPIO controller for the specified I2C expander type. + """Create a GPIO controller for the specified I2C expander type and board. Args: device_type: The type of I2C expander (MCP23017, PCF8574, PCA9535) pin: The pin number on the expander + board_number: The board number to target (default 1) invert_override: Optional override for pin inversion. If None, uses config value. Returns: A SinglePinController for the specified pin. """ - config = self.get_config_for_type(device_type) + config = self.get_config_for_type(device_type, board_number) default_inverted = config.inverted if config else False inverted = invert_override if invert_override is not None else default_inverted if config is None: - _logger.error(f"No enabled config found for device type {device_type}, please check your configuration.") + _logger.error( + f"No enabled config found for device type {device_type} board {board_number}, " + "please check your configuration." + ) match device_type: case "MCP23017": - return MCP23017GPIO(pin, inverted, self._mcp23017) + return MCP23017GPIO(pin, inverted, self._mcp23017.get(board_number)) case "PCF8574": - return PCF8574GPIO(pin, inverted, self._pcf8574) + return PCF8574GPIO(pin, inverted, self._pcf8574.get(board_number)) case "PCA9535": - return PCA9535GPIO(pin, inverted, self._pca9535) + return PCA9535GPIO(pin, inverted, self._pca9535.get(board_number)) @property def has_enabled_devices(self) -> bool: """Check if any I2C expander is enabled and initialized.""" - return self._mcp23017 is not None or self._pcf8574 is not None or self._pca9535 is not None + return bool(self._mcp23017 or self._pcf8574 or self._pca9535) diff --git a/src/machine/pin_controller.py b/src/machine/pin_controller.py index 0f440dbc..34fa2b57 100644 --- a/src/machine/pin_controller.py +++ b/src/machine/pin_controller.py @@ -24,6 +24,7 @@ def generate_single_pin_controller( self, pin: int, pin_type: SupportedPinControlType, + board_number: int = 1, invert_override: bool | None = None, ) -> SinglePinController: """Return the appropriate GPIO. @@ -33,7 +34,7 @@ def generate_single_pin_controller( gpio_inverted = cfg.MAKER_PINS_INVERTED if invert_override is None else invert_override match pin_type: case "MCP23017" | "PCF8574" | "PCA9535": - return self._i2c_factory.create_i2c_gpio(pin_type, pin, invert_override) + return self._i2c_factory.create_i2c_gpio(pin_type, pin, board_number, invert_override) case "GPIO": if is_rpi5(): return Rpi5GPIO(pin, gpio_inverted) @@ -71,7 +72,9 @@ def initialize_pin( invert_override: bool | None = None, ) -> SinglePinController: """Create and initialize a SinglePinController for a pin.""" - controller = self._factory.generate_single_pin_controller(pin_id.pin, pin_id.pin_type, invert_override) + controller = self._factory.generate_single_pin_controller( + pin_id.pin, pin_id.pin_type, pin_id.board_number, invert_override + ) controller.initialize(is_input, pull_down) self._pins[pin_id] = controller return controller diff --git a/tests/config/test_config_manager.py b/tests/config/test_config_manager.py index 852aad79..63ad026f 100644 --- a/tests/config/test_config_manager.py +++ b/tests/config/test_config_manager.py @@ -151,7 +151,9 @@ def test_set_config_i2c_pump_requires_i2c_config(self) -> None: config.set_config( { "MAKER_NUMBER_BOTTLES": 1, - "PUMP_CONFIG": [{"pin": 0, "volume_flow": 30.0, "tube_volume": 5, "pin_type": "MCP23017"}], + "PUMP_CONFIG": [ + {"pin": 0, "volume_flow": 30.0, "tube_volume": 5, "pin_type": "MCP23017", "board_number": 1} + ], "I2C_CONFIG": [], }, validate=True, @@ -163,8 +165,18 @@ def test_set_config_i2c_pump_with_matching_i2c_config_succeeds(self) -> None: config.set_config( { "MAKER_NUMBER_BOTTLES": 1, - "PUMP_CONFIG": [{"pin": 0, "volume_flow": 30.0, "tube_volume": 5, "pin_type": "MCP23017"}], - "I2C_CONFIG": [{"device_type": "MCP23017", "enabled": True, "address_int": 20, "inverted": False}], + "PUMP_CONFIG": [ + {"pin": 0, "volume_flow": 30.0, "tube_volume": 5, "pin_type": "MCP23017", "board_number": 1} + ], + "I2C_CONFIG": [ + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 20, + "inverted": False, + "board_number": 1, + } + ], }, validate=True, ) @@ -178,8 +190,18 @@ def test_set_config_i2c_pump_with_disabled_i2c_config_fails(self) -> None: config.set_config( { "MAKER_NUMBER_BOTTLES": 1, - "PUMP_CONFIG": [{"pin": 0, "volume_flow": 30.0, "tube_volume": 5, "pin_type": "PCA9535"}], - "I2C_CONFIG": [{"device_type": "PCA9535", "enabled": False, "address_int": 20, "inverted": False}], + "PUMP_CONFIG": [ + {"pin": 0, "volume_flow": 30.0, "tube_volume": 5, "pin_type": "PCA9535", "board_number": 1} + ], + "I2C_CONFIG": [ + { + "device_type": "PCA9535", + "enabled": False, + "address_int": 20, + "inverted": False, + "board_number": 1, + } + ], }, validate=True, ) @@ -198,33 +220,85 @@ def test_set_config_gpio_pump_does_not_require_i2c_config(self) -> None: assert len(config.PUMP_CONFIG) == 1 assert config.PUMP_CONFIG[0].pin_type == "GPIO" - def test_set_config_i2c_config_rejects_duplicate_enabled_device_types(self) -> None: - """Test that I2C_CONFIG rejects duplicate enabled device types.""" + def test_set_config_i2c_config_rejects_duplicate_device_type_and_board(self) -> None: + """Test that I2C_CONFIG rejects duplicate (device_type, board_number) combinations.""" + config = ConfigManager() + with pytest.raises(ConfigError, match="duplicate entries"): + config.set_config( + { + "I2C_CONFIG": [ + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 20, + "inverted": False, + "board_number": 1, + }, + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 21, + "inverted": False, + "board_number": 1, + }, + ], + }, + validate=True, + ) + + def test_set_config_i2c_config_rejects_duplicate_disabled_same_board(self) -> None: + """Test that I2C_CONFIG rejects duplicate (device_type, board_number) even if disabled.""" config = ConfigManager() - with pytest.raises(ConfigError, match="duplicate enabled device types"): + with pytest.raises(ConfigError, match="duplicate entries"): config.set_config( { "I2C_CONFIG": [ - {"device_type": "MCP23017", "enabled": True, "address_int": 20, "inverted": False}, - {"device_type": "MCP23017", "enabled": True, "address_int": 21, "inverted": False}, + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 20, + "inverted": False, + "board_number": 1, + }, + { + "device_type": "MCP23017", + "enabled": False, + "address_int": 21, + "inverted": False, + "board_number": 1, + }, ], }, validate=True, ) - def test_set_config_i2c_config_allows_duplicate_disabled_device_types(self) -> None: - """Test that I2C_CONFIG allows duplicate disabled device types.""" + def test_set_config_i2c_config_allows_same_type_different_boards(self) -> None: + """Test that I2C_CONFIG allows same device type with different board numbers.""" config = ConfigManager() config.set_config( { "I2C_CONFIG": [ - {"device_type": "MCP23017", "enabled": True, "address_int": 20, "inverted": False}, - {"device_type": "MCP23017", "enabled": False, "address_int": 21, "inverted": False}, + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 20, + "inverted": False, + "board_number": 1, + }, + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 21, + "inverted": False, + "board_number": 2, + }, ], }, validate=True, ) assert len(config.I2C_CONFIG) == 2 + assert config.I2C_CONFIG[0].board_number == 1 + assert config.I2C_CONFIG[1].board_number == 2 def test_set_config_i2c_config_allows_different_enabled_device_types(self) -> None: """Test that I2C_CONFIG allows different enabled device types.""" @@ -232,14 +306,106 @@ def test_set_config_i2c_config_allows_different_enabled_device_types(self) -> No config.set_config( { "I2C_CONFIG": [ - {"device_type": "MCP23017", "enabled": True, "address_int": 20, "inverted": False}, - {"device_type": "PCA9535", "enabled": True, "address_int": 21, "inverted": False}, + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 20, + "inverted": False, + "board_number": 1, + }, + { + "device_type": "PCA9535", + "enabled": True, + "address_int": 21, + "inverted": False, + "board_number": 1, + }, ], }, validate=True, ) assert len(config.I2C_CONFIG) == 2 + def test_set_config_i2c_pump_requires_matching_board_number(self) -> None: + """Test that PUMP_CONFIG I2C board_number must match an I2C_CONFIG entry.""" + config = ConfigManager() + with pytest.raises(ConfigError): + config.set_config( + { + "MAKER_NUMBER_BOTTLES": 1, + "PUMP_CONFIG": [ + {"pin": 0, "volume_flow": 30.0, "tube_volume": 5, "pin_type": "MCP23017", "board_number": 2} + ], + "I2C_CONFIG": [ + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 20, + "inverted": False, + "board_number": 1, + } + ], + }, + validate=True, + ) + + def test_set_config_pump_config_rejects_duplicate_pins_same_board(self) -> None: + """Test that PUMP_CONFIG rejects duplicate (pin_type, board_number, pin) combinations.""" + config = ConfigManager() + with pytest.raises(ConfigError, match="duplicate entries"): + config.set_config( + { + "MAKER_NUMBER_BOTTLES": 2, + "PUMP_CONFIG": [ + {"pin": 0, "volume_flow": 30.0, "tube_volume": 5, "pin_type": "MCP23017", "board_number": 1}, + {"pin": 0, "volume_flow": 25.0, "tube_volume": 3, "pin_type": "MCP23017", "board_number": 1}, + ], + "I2C_CONFIG": [ + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 20, + "inverted": False, + "board_number": 1, + } + ], + }, + validate=True, + ) + + def test_set_config_pump_config_allows_same_pin_different_boards(self) -> None: + """Test that PUMP_CONFIG allows same pin number on different boards.""" + config = ConfigManager() + config.set_config( + { + "MAKER_NUMBER_BOTTLES": 2, + "PUMP_CONFIG": [ + {"pin": 0, "volume_flow": 30.0, "tube_volume": 5, "pin_type": "MCP23017", "board_number": 1}, + {"pin": 0, "volume_flow": 25.0, "tube_volume": 3, "pin_type": "MCP23017", "board_number": 2}, + ], + "I2C_CONFIG": [ + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 20, + "inverted": False, + "board_number": 1, + }, + { + "device_type": "MCP23017", + "enabled": True, + "address_int": 21, + "inverted": False, + "board_number": 2, + }, + ], + }, + validate=True, + ) + assert len(config.PUMP_CONFIG) == 2 + assert config.PUMP_CONFIG[0].board_number == 1 + assert config.PUMP_CONFIG[1].board_number == 2 + class TestConfigManagerReadLocalConfig: """Tests for ConfigManager.read_local_config() method.""" diff --git a/tests/test_machine_controller.py b/tests/test_machine_controller.py index e476173a..c69afb82 100644 --- a/tests/test_machine_controller.py +++ b/tests/test_machine_controller.py @@ -64,12 +64,12 @@ def test_build_preparation_data(self): # Verify results assert len(prep_data) == 2 - assert prep_data[0].pin == PinId("GPIO", 1) + assert prep_data[0].pin == PinId("GPIO", 1, 1) assert prep_data[0].volume_flow == pytest.approx(10.0) assert prep_data[0].flow_time == pytest.approx(10.0) # 100ml / 10ml/s assert prep_data[0].recipe_order == 1 - assert prep_data[1].pin == PinId("GPIO", 2) + assert prep_data[1].pin == PinId("GPIO", 1, 2) assert prep_data[1].volume_flow == pytest.approx(10.0) # 20.0 * 0.5 (pump_speed 50%) assert prep_data[1].flow_time == pytest.approx(20.0) # 200ml / 10ml/s assert prep_data[1].recipe_order == 2 @@ -89,10 +89,10 @@ def test_chunk_preparation_data(self): # Create test data prep_data = [ - _PreparationData(pin=PinId("GPIO", 1), volume_flow=10, flow_time=5, recipe_order=1), - _PreparationData(pin=PinId("GPIO", 2), volume_flow=10, flow_time=5, recipe_order=1), - _PreparationData(pin=PinId("GPIO", 3), volume_flow=10, flow_time=5, recipe_order=1), - _PreparationData(pin=PinId("GPIO", 4), volume_flow=10, flow_time=5, recipe_order=2), + _PreparationData(pin=PinId("GPIO", 1, 1), volume_flow=10, flow_time=5, recipe_order=1), + _PreparationData(pin=PinId("GPIO", 1, 2), volume_flow=10, flow_time=5, recipe_order=1), + _PreparationData(pin=PinId("GPIO", 1, 3), volume_flow=10, flow_time=5, recipe_order=1), + _PreparationData(pin=PinId("GPIO", 1, 4), volume_flow=10, flow_time=5, recipe_order=2), ] mc = MachineController() @@ -112,8 +112,8 @@ def test_chunk_preparation_data(self): def test_process_preparation_section(self): # Create test section data section = [ - _PreparationData(pin=PinId("GPIO", 1), volume_flow=10, flow_time=5), - _PreparationData(pin=PinId("GPIO", 2), volume_flow=20, flow_time=3), + _PreparationData(pin=PinId("GPIO", 1, 1), volume_flow=10, flow_time=5), + _PreparationData(pin=PinId("GPIO", 1, 2), volume_flow=20, flow_time=3), ] mc = MachineController() @@ -132,13 +132,13 @@ def test_process_preparation_section(self): mc._process_preparation_section(0, 10, section, section_time=4) assert section[0].consumption == 40 # 10 ml/s * 4s assert section[1].closed - mc._stop_pumps.assert_called_once_with([PinId("GPIO", 2)], ANY) + mc._stop_pumps.assert_called_once_with([PinId("GPIO", 1, 2)], ANY) # Third call: section_time > flow_time for pin=1 mc._process_preparation_section(0, 10, section, section_time=6) assert section[0].closed assert mc._stop_pumps.call_count == 2 - mc._stop_pumps.assert_any_call([PinId("GPIO", 1)], ANY) + mc._stop_pumps.assert_any_call([PinId("GPIO", 1, 1)], ANY) @patch("time.perf_counter") def test_start_preparation(self, mock_time: MagicMock): @@ -154,8 +154,8 @@ def test_start_preparation(self, mock_time: MagicMock): # Create test data with two ingredients with different recipe orders prep_data = [ - _PreparationData(pin=PinId("GPIO", 1), volume_flow=10, flow_time=1.0, recipe_order=1), - _PreparationData(pin=PinId("GPIO", 2), volume_flow=10, flow_time=1.0, recipe_order=2), + _PreparationData(pin=PinId("GPIO", 1, 1), volume_flow=10, flow_time=1.0, recipe_order=1), + _PreparationData(pin=PinId("GPIO", 1, 2), volume_flow=10, flow_time=1.0, recipe_order=2), ] mc = MachineController() @@ -173,13 +173,13 @@ def test_start_preparation(self, mock_time: MagicMock): # Verify _start_pumps was called for both chunks with correct pins assert mc._start_pumps.call_count == 2 - mc._start_pumps.assert_any_call([PinId("GPIO", 1)], ANY) # First ingredient - mc._start_pumps.assert_any_call([PinId("GPIO", 2)], ANY) # Second ingredient + mc._start_pumps.assert_any_call([PinId("GPIO", 1, 1)], ANY) # First ingredient + mc._start_pumps.assert_any_call([PinId("GPIO", 1, 2)], ANY) # Second ingredient # Verify _stop_pumps was called for both chunks with correct pins assert mc._stop_pumps.call_count == 2 - mc._stop_pumps.assert_any_call([PinId("GPIO", 1)], ANY) # First ingredient - mc._stop_pumps.assert_any_call([PinId("GPIO", 2)], ANY) # Second ingredient + mc._stop_pumps.assert_any_call([PinId("GPIO", 1, 1)], ANY) # First ingredient + mc._stop_pumps.assert_any_call([PinId("GPIO", 1, 2)], ANY) # Second ingredient # Verify _process_preparation_section was called multiple times for each chunk assert mc._process_preparation_section.call_count >= 2 diff --git a/web_client/src/components/common/ListDisplay/index.stories.tsx b/web_client/src/components/common/ListDisplay/index.stories.tsx index 6c9b2453..e0d4c5fd 100644 --- a/web_client/src/components/common/ListDisplay/index.stories.tsx +++ b/web_client/src/components/common/ListDisplay/index.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import NumberInput from '../NumberInput'; +import ObjectDisplay from '../ObjectDisplay'; +import TextInput from '../TextInput'; import ListDisplay from '.'; const meta: Meta = { @@ -62,3 +64,24 @@ export const WithManyItems: Story = { onRemove: (index) => console.log('Remove', index), }, }; + +const noop = () => {}; + +export const DividedWithObjects: Story = { + args: { + children: Array.from({ length: 4 }, (_, i) => ( + + {[ + , + , + , + ]} + + )), + divided: true, + immutable: false, + defaultValue: '', + onAdd: (value) => console.log('Add', value), + onRemove: (index) => console.log('Remove', index), + }, +}; diff --git a/web_client/src/components/common/ListDisplay/index.tsx b/web_client/src/components/common/ListDisplay/index.tsx index e7d9469e..178fe699 100644 --- a/web_client/src/components/common/ListDisplay/index.tsx +++ b/web_client/src/components/common/ListDisplay/index.tsx @@ -5,22 +5,30 @@ import CloseButton from '../CloseButton'; interface ListDisplayProps { children: React.ReactNode[]; defaultValue: T; + divided?: boolean; immutable: boolean; onAdd?: (value: T) => void; onRemove?: (index: number) => void; } -const ListDisplay = ({ children, defaultValue, immutable, onAdd, onRemove }: ListDisplayProps) => { +const divideClasses = 'divide-y divide-[color-mix(in_srgb,var(--neutral-color)_50%,transparent)]'; + +const ListDisplay = ({ children, defaultValue, divided, immutable, onAdd, onRemove }: ListDisplayProps) => { return (
- {children.map((item, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: is always ordered here -
- {index + 1} - {item} - {!immutable && onRemove?.(index)} />} -
- ))} +
+ {children.map((item, index) => ( +
+ {index + 1} + {item} + {!immutable && onRemove?.(index)} />} +
+ ))} +
{!immutable && (