diff --git a/README.md b/README.md index db34276..55b73fe 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,14 @@ pip install python-omnilogic-local[cli] ```python import asyncio -from pyomnilogic_local import OmniLogic +from pyomnilogic_local import OmniLogic, OmniLogicConfig async def main(): # Connect to your OmniLogic controller - omni = OmniLogic("192.168.1.100") + config = OmniLogicConfig( + host="192.168.1.100" + ) + omni = OmniLogic(config) # Initial refresh to load configuration and state await omni.refresh() @@ -110,7 +113,11 @@ asyncio.run(main()) ```python async def monitor_pool(): - omni = OmniLogic("192.168.1.100") + config = OmniLogicConfig( + host="192.168.1.100" + ) + omni = OmniLogic(config) + await omni.refresh() pool = omni.backyard.bow["Pool"] @@ -140,8 +147,11 @@ asyncio.run(monitor_pool()) The library includes intelligent state management to minimize unnecessary API calls: ```python -# Force immediate refresh -await omni.refresh(force=True) +# Force immediate refresh of Telemetry +await omni.refresh(force_telemetry=True) + +# Force immediate refresh of MSP Config +await omni.refresh(force_mspconfig=True) # Refresh only if data is older than 30 seconds await omni.refresh(if_older_than=30.0) diff --git a/pyomnilogic_local/__init__.py b/pyomnilogic_local/__init__.py index b3bd908..07e05e2 100644 --- a/pyomnilogic_local/__init__.py +++ b/pyomnilogic_local/__init__.py @@ -15,7 +15,7 @@ from .groups import Group from .heater import Heater from .heater_equip import HeaterEquipment -from .omnilogic import OmniLogic +from .omnilogic import OmniLogic, OmniLogicConfig from .pump import Pump from .relay import Relay from .schedule import Schedule @@ -47,6 +47,7 @@ "OmniEquipmentNotInitializedError", "OmniEquipmentNotReadyError", "OmniLogic", + "OmniLogicConfig", "OmniLogicLocalError", "Pump", "Relay", diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py index 17d8a10..df0d478 100644 --- a/pyomnilogic_local/backyard.py +++ b/pyomnilogic_local/backyard.py @@ -97,10 +97,10 @@ class Backyard(OmniEquipment[MSPBackyard, TelemetryBackyard]): mspconfig: MSPBackyard telemetry: TelemetryBackyard - bow: EquipmentDict[Bow] = EquipmentDict() - lights: EquipmentDict[ColorLogicLight] = EquipmentDict() - relays: EquipmentDict[Relay] = EquipmentDict() - sensors: EquipmentDict[Sensor] = EquipmentDict() + bow: EquipmentDict[Bow] + lights: EquipmentDict[ColorLogicLight] + relays: EquipmentDict[Relay] + sensors: EquipmentDict[Sensor] def __init__(self, omni: OmniLogic, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @@ -169,7 +169,7 @@ def _update_bows(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: self.bow = EquipmentDict() return - self.bow = EquipmentDict([Bow(self._omni, bow, telemetry) for bow in mspconfig.bow]) + self.bow = self._omni._make_equipment_dict([Bow(self._omni, bow, telemetry) for bow in mspconfig.bow]) def _update_lights(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: """Update the lights based on the MSP configuration.""" @@ -177,7 +177,9 @@ def _update_lights(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: self.lights = EquipmentDict() return - self.lights = EquipmentDict([ColorLogicLight(self._omni, light, telemetry) for light in mspconfig.colorlogic_light]) + self.lights = self._omni._make_equipment_dict( + [ColorLogicLight(self._omni, light, telemetry) for light in mspconfig.colorlogic_light] + ) def _update_relays(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: """Update the relays based on the MSP configuration.""" @@ -185,7 +187,7 @@ def _update_relays(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: self.relays = EquipmentDict() return - self.relays = EquipmentDict([Relay(self._omni, relay, telemetry) for relay in mspconfig.relay]) + self.relays = self._omni._make_equipment_dict([Relay(self._omni, relay, telemetry) for relay in mspconfig.relay]) def _update_sensors(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: """Update the sensors based on the MSP configuration.""" @@ -193,4 +195,4 @@ def _update_sensors(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: self.sensors = EquipmentDict() return - self.sensors = EquipmentDict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor]) + self.sensors = self._omni._make_equipment_dict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor]) diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index f16ce0f..8c3da65 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -132,12 +132,12 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): mspconfig: MSPBoW telemetry: TelemetryBoW - filters: EquipmentDict[Filter] = EquipmentDict() + filters: EquipmentDict[Filter] heater: Heater | None = None - relays: EquipmentDict[Relay] = EquipmentDict() - sensors: EquipmentDict[Sensor] = EquipmentDict() - lights: EquipmentDict[ColorLogicLight] = EquipmentDict() - pumps: EquipmentDict[Pump] = EquipmentDict() + relays: EquipmentDict[Relay] + sensors: EquipmentDict[Sensor] + lights: EquipmentDict[ColorLogicLight] + pumps: EquipmentDict[Pump] chlorinator: Chlorinator | None = None csad: CSAD | None = None @@ -288,7 +288,7 @@ def _update_filters(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.filters = EquipmentDict() return - self.filters = EquipmentDict([Filter(self._omni, filter_, telemetry) for filter_ in mspconfig.filter]) + self.filters = self._omni._make_equipment_dict([Filter(self._omni, filter_, telemetry) for filter_ in mspconfig.filter]) def _update_heater(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the heater based on the MSP configuration.""" @@ -304,7 +304,9 @@ def _update_lights(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.lights = EquipmentDict() return - self.lights = EquipmentDict([ColorLogicLight(self._omni, light, telemetry) for light in mspconfig.colorlogic_light]) + self.lights = self._omni._make_equipment_dict( + [ColorLogicLight(self._omni, light, telemetry) for light in mspconfig.colorlogic_light] + ) def _update_pumps(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the pumps based on the MSP configuration.""" @@ -312,7 +314,7 @@ def _update_pumps(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.pumps = EquipmentDict() return - self.pumps = EquipmentDict([Pump(self._omni, pump, telemetry) for pump in mspconfig.pump]) + self.pumps = self._omni._make_equipment_dict([Pump(self._omni, pump, telemetry) for pump in mspconfig.pump]) def _update_relays(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the relays based on the MSP configuration.""" @@ -320,7 +322,7 @@ def _update_relays(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.relays = EquipmentDict() return - self.relays = EquipmentDict([Relay(self._omni, relay, telemetry) for relay in mspconfig.relay]) + self.relays = self._omni._make_equipment_dict([Relay(self._omni, relay, telemetry) for relay in mspconfig.relay]) def _update_sensors(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the sensors based on the MSP configuration.""" @@ -328,4 +330,4 @@ def _update_sensors(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.sensors = EquipmentDict() return - self.sensors = EquipmentDict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor]) + self.sensors = self._omni._make_equipment_dict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor]) diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py index 773d067..0f4d633 100644 --- a/pyomnilogic_local/chlorinator.py +++ b/pyomnilogic_local/chlorinator.py @@ -45,7 +45,7 @@ class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): mspconfig: MSPChlorinator telemetry: TelemetryChlorinator - chlorinator_equipment: EquipmentDict[ChlorinatorEquipment] = EquipmentDict() + chlorinator_equipment: EquipmentDict[ChlorinatorEquipment] def __init__(self, omni: OmniLogic, mspconfig: MSPChlorinator, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @@ -62,7 +62,7 @@ def _update_chlorinator_equipment(self, mspconfig: MSPChlorinator, telemetry: Te self.chlorinator_equipment = EquipmentDict() return - self.chlorinator_equipment = EquipmentDict( + self.chlorinator_equipment = self._omni._make_equipment_dict( [ChlorinatorEquipment(self._omni, equip, telemetry) for equip in mspconfig.chlorinator_equipment] ) diff --git a/pyomnilogic_local/cli/cli.py b/pyomnilogic_local/cli/cli.py index 7749cff..ffeb24d 100644 --- a/pyomnilogic_local/cli/cli.py +++ b/pyomnilogic_local/cli/cli.py @@ -5,7 +5,7 @@ import click -from pyomnilogic_local import OmniLogic +from pyomnilogic_local import OmniLogic, OmniLogicConfig from pyomnilogic_local.cli.debug import commands as debug from pyomnilogic_local.cli.get import commands as get @@ -39,16 +39,22 @@ def entrypoint(ctx: click.Context, host: str, port: int, timeout: int, debug: bo """ ctx.ensure_object(dict) - if debug: - logging.basicConfig(level=logging.DEBUG) + logging.basicConfig( + level=logging.DEBUG if debug else logging.INFO, + ) # Store the host for later connection, but don't connect yet - ctx.obj["HOST"] = host - ctx.obj["PORT"] = port - ctx.obj["TIMEOUT"] = timeout - omnilogic = OmniLogic(host, port, timeout) # Store the OmniLogic instance for later use - - asyncio.run(omnilogic.refresh(force=True)) + config = OmniLogicConfig( + host=host, + port=port, + timeout=timeout, + # The CLI should only ever reference things by their system_id + # so we can ignore duplicate name warnings + warn_duplicate_equipment_names=False, + ) + omnilogic = OmniLogic(config) # Store the OmniLogic instance for later use + + asyncio.run(omnilogic.refresh()) ctx.obj["OMNILOGIC"] = omnilogic diff --git a/pyomnilogic_local/collections.py b/pyomnilogic_local/collections.py index be1f39b..5db15d5 100644 --- a/pyomnilogic_local/collections.py +++ b/pyomnilogic_local/collections.py @@ -57,16 +57,18 @@ class EquipmentDict[OE: OmniEquipment[Any, Any]]: always lookup by name. This type-based differentiation prevents ambiguity. """ - def __init__(self, items: list[OE] | None = None) -> None: + def __init__(self, items: list[OE] | None = None, warn_duplicates: bool = True) -> None: """Initialize the equipment collection. Args: items: Optional list of equipment items to populate the collection. + warn_duplicates: Whether to log warnings for duplicate names. Raises: ValueError: If any item has neither a system_id nor a name. """ self._items: list[OE] = items if items is not None else [] + self._warn_duplicates = warn_duplicates self._validate() def _validate(self) -> None: @@ -89,21 +91,22 @@ def _validate(self) -> None: raise ValueError(msg) # Find duplicate names that we haven't warned about yet - name_counts = Counter(item.name for item in self._items if item.name is not None) - duplicate_names = {name for name, count in name_counts.items() if count > 1} - unwarned_duplicates = duplicate_names.difference(_WARNED_DUPLICATE_NAMES) - - # Log warnings for new duplicates - for name in unwarned_duplicates: - _LOGGER.warning( - "Equipment collection contains %d items with the same name '%s'. " - "Name-based lookups will return the first match. " - "Consider using system_id-based lookups for reliability " - "or renaming equipment to avoid duplicates.", - name_counts[name], - name, - ) - _WARNED_DUPLICATE_NAMES.add(name) + if self._warn_duplicates: + name_counts = Counter(item.name for item in self._items if item.name is not None) + duplicate_names = {name for name, count in name_counts.items() if count > 1} + unwarned_duplicates = duplicate_names.difference(_WARNED_DUPLICATE_NAMES) + + # Log warnings for new duplicates + for name in unwarned_duplicates: + _LOGGER.warning( + "Equipment collection contains %d items with the same name '%s'. " + "Name-based lookups will return the first match. " + "Consider using system_id-based lookups for reliability " + "or renaming equipment to avoid duplicates.", + name_counts[name], + name, + ) + _WARNED_DUPLICATE_NAMES.add(name) @property def _by_name(self) -> dict[str, OE]: diff --git a/pyomnilogic_local/csad.py b/pyomnilogic_local/csad.py index 026cad7..afa393f 100644 --- a/pyomnilogic_local/csad.py +++ b/pyomnilogic_local/csad.py @@ -45,7 +45,7 @@ class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]): mspconfig: MSPCSAD telemetry: TelemetryCSAD - csad_equipment: EquipmentDict[CSADEquipment] = EquipmentDict() + csad_equipment: EquipmentDict[CSADEquipment] def __init__(self, omni: OmniLogic, mspconfig: MSPCSAD, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @@ -62,7 +62,9 @@ def _update_csad_equipment(self, mspconfig: MSPCSAD, telemetry: Telemetry) -> No self.csad_equipment = EquipmentDict() return - self.csad_equipment = EquipmentDict([CSADEquipment(self._omni, equip, telemetry) for equip in mspconfig.csad_equipment]) + self.csad_equipment = self._omni._make_equipment_dict( + [CSADEquipment(self._omni, equip, telemetry) for equip in mspconfig.csad_equipment] + ) # Expose MSPConfig attributes @property diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py index 18efe6f..3b3ab86 100644 --- a/pyomnilogic_local/heater.py +++ b/pyomnilogic_local/heater.py @@ -102,7 +102,7 @@ class Heater(OmniEquipment[MSPVirtualHeater, TelemetryVirtualHeater]): mspconfig: MSPVirtualHeater telemetry: TelemetryVirtualHeater - heater_equipment: EquipmentDict[HeaterEquipment] = EquipmentDict() + heater_equipment: EquipmentDict[HeaterEquipment] def __init__(self, omni: OmniLogic, mspconfig: MSPVirtualHeater, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @@ -119,7 +119,9 @@ def _update_heater_equipment(self, mspconfig: MSPVirtualHeater, telemetry: Telem self.heater_equipment = EquipmentDict() return - self.heater_equipment = EquipmentDict([HeaterEquipment(self._omni, equip, telemetry) for equip in mspconfig.heater_equipment]) + self.heater_equipment = self._omni._make_equipment_dict( + [HeaterEquipment(self._omni, equip, telemetry) for equip in mspconfig.heater_equipment] + ) @property def max_temp(self) -> int: diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index ffec182..5af1e9b 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -4,6 +4,7 @@ import logging import os import time +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from pyomnilogic_local.api import OmniLogicAPI @@ -35,6 +36,23 @@ _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True) +class OmniLogicConfig: + host: str + port: int = 10444 + timeout: float = DEFAULT_RESPONSE_TIMEOUT + + # By default, we only poll the telemetry and mspconfig when refreshing data + # More optional data polls will be added in the future + poll_telemetry: bool = True + poll_mspconfig: bool = True + + # This controls if EquipmentDicts will log warnings if multiple pieces of equipment + # have the same name. If you only address equipment by system_id, you can disable this + # warning to reduce log noise. + warn_duplicate_equipment_names: bool = True + + class OmniLogic: mspconfig: MSPConfig telemetry: Telemetry @@ -44,6 +62,7 @@ class OmniLogic: groups: EquipmentDict[Group] schedules: EquipmentDict[Schedule] + config: OmniLogicConfig _api: OmniLogicAPI | OmniLogicMockAPI _mspconfig_last_updated: float = 0.0 _telemetry_last_updated: float = 0.0 @@ -54,15 +73,15 @@ class OmniLogic: _min_mspversion: str = "R05" _warned_mspversion: bool = False - def __init__(self, host: str, port: int = 10444, timeout: float = DEFAULT_RESPONSE_TIMEOUT) -> None: - self.host = host - self.port = port + def __init__(self, config: OmniLogicConfig) -> None: + + self.config = config sim_data_path = os.environ.get("PYOMNILOGIC_SIMULATION_DATA") if sim_data_path: self._api = OmniLogicMockAPI(sim_data_path) else: - self._api = OmniLogicAPI(host, port, timeout) + self._api = OmniLogicAPI(config.host, config.port, config.timeout) self._refresh_lock = asyncio.Lock() def __repr__(self) -> str: @@ -80,11 +99,11 @@ def __repr__(self) -> str: filter_count = len(self.all_filters) return ( - f"OmniLogic(host={self.host!r}, port={self.port}, " + f"OmniLogic(host={self._api.controller_ip!r}, port={self.config.port}, " f"bows={bow_count}, lights={light_count}, relays={relay_count}, " f"pumps={pump_count}, filters={filter_count})" ) - return f"OmniLogic(host={self.host!r}, port={self.port}, not_initialized=True)" + return f"OmniLogic(host={self._api.controller_ip!r}, port={self.config.port}, not_initialized=True)" async def refresh( self, @@ -107,6 +126,12 @@ async def refresh( force: Force refresh of telemetry and MSPConfig (deprecated, use individual force flags instead) """ if force: + if not getattr(self, "_warn_deprecated_force", False): + _LOGGER.warning( + "The 'force' parameter to OmniLogic.refresh() is deprecated and will be removed " + "in a future version. Use 'force_telemetry' and 'force_mspconfig' instead.", + ) + self._warn_deprecated_force = True force_telemetry = True force_mspconfig = True @@ -170,15 +195,23 @@ def _update_equipment(self) -> None: # Update groups if self.mspconfig.groups is None: - self.groups = EquipmentDict() + self.groups = self._make_equipment_dict() else: - self.groups = EquipmentDict([Group(self, group_, self.telemetry) for group_ in self.mspconfig.groups]) + self.groups = self._make_equipment_dict( + [Group(self, group_, self.telemetry) for group_ in self.mspconfig.groups], + ) # Update schedules if self.mspconfig.schedules is None: - self.schedules = EquipmentDict() + self.schedules = self._make_equipment_dict() else: - self.schedules = EquipmentDict([Schedule(self, schedule_, self.telemetry) for schedule_ in self.mspconfig.schedules]) + self.schedules = self._make_equipment_dict( + [Schedule(self, schedule_, self.telemetry) for schedule_ in self.mspconfig.schedules], + ) + + def _make_equipment_dict[OE: OmniEquipment[Any, Any]](self, items: list[OE] | None = None) -> EquipmentDict[OE]: + """Create an EquipmentDict pre-configured with this instance's warn_duplicates setting.""" + return EquipmentDict(items, warn_duplicates=self.config.warn_duplicate_equipment_names) # Equipment discovery properties @property @@ -190,7 +223,7 @@ def all_lights(self) -> EquipmentDict[ColorLogicLight]: # Lights in each bow for bow in self.backyard.bow.values(): lights.extend(bow.lights.values()) - return EquipmentDict(lights) + return self._make_equipment_dict(lights) @property def all_relays(self) -> EquipmentDict[Relay]: @@ -201,7 +234,7 @@ def all_relays(self) -> EquipmentDict[Relay]: # Relays in each bow for bow in self.backyard.bow.values(): relays.extend(bow.relays.values()) - return EquipmentDict(relays) + return self._make_equipment_dict(relays) @property def all_pumps(self) -> EquipmentDict[Pump]: @@ -209,7 +242,7 @@ def all_pumps(self) -> EquipmentDict[Pump]: pumps: list[Pump] = [] for bow in self.backyard.bow.values(): pumps.extend(bow.pumps.values()) - return EquipmentDict(pumps) + return self._make_equipment_dict(pumps) @property def all_filters(self) -> EquipmentDict[Filter]: @@ -217,7 +250,7 @@ def all_filters(self) -> EquipmentDict[Filter]: filters: list[Filter] = [] for bow in self.backyard.bow.values(): filters.extend(bow.filters.values()) - return EquipmentDict(filters) + return self._make_equipment_dict(filters) @property def all_sensors(self) -> EquipmentDict[Sensor]: @@ -228,13 +261,13 @@ def all_sensors(self) -> EquipmentDict[Sensor]: # Sensors in each bow for bow in self.backyard.bow.values(): sensors.extend(bow.sensors.values()) - return EquipmentDict(sensors) + return self._make_equipment_dict(sensors) @property def all_heaters(self) -> EquipmentDict[Heater]: """Returns all Heater (VirtualHeater) instances across all bows in the backyard.""" heaters = [bow.heater for bow in self.backyard.bow.values() if bow.heater is not None] - return EquipmentDict(heaters) + return self._make_equipment_dict(heaters) @property def all_heater_equipment(self) -> EquipmentDict[HeaterEquipment]: @@ -242,13 +275,13 @@ def all_heater_equipment(self) -> EquipmentDict[HeaterEquipment]: heater_equipment: list[HeaterEquipment] = [] for heater in self.all_heaters.values(): heater_equipment.extend(heater.heater_equipment.values()) - return EquipmentDict(heater_equipment) + return self._make_equipment_dict(heater_equipment) @property def all_chlorinators(self) -> EquipmentDict[Chlorinator]: """Returns all Chlorinator instances across all bows in the backyard.""" chlorinators = [bow.chlorinator for bow in self.backyard.bow.values() if bow.chlorinator is not None] - return EquipmentDict(chlorinators) + return self._make_equipment_dict(chlorinators) @property def all_chlorinator_equipment(self) -> EquipmentDict[ChlorinatorEquipment]: @@ -256,7 +289,7 @@ def all_chlorinator_equipment(self) -> EquipmentDict[ChlorinatorEquipment]: chlorinator_equipment: list[ChlorinatorEquipment] = [] for chlorinator in self.all_chlorinators.values(): chlorinator_equipment.extend(chlorinator.chlorinator_equipment.values()) - return EquipmentDict(chlorinator_equipment) + return self._make_equipment_dict(chlorinator_equipment) @property def all_csad_equipment(self) -> EquipmentDict[CSADEquipment]: @@ -264,13 +297,13 @@ def all_csad_equipment(self) -> EquipmentDict[CSADEquipment]: csad_equipment: list[CSADEquipment] = [] for csad in self.all_csads.values(): csad_equipment.extend(csad.csad_equipment.values()) - return EquipmentDict(csad_equipment) + return self._make_equipment_dict(csad_equipment) @property def all_csads(self) -> EquipmentDict[CSAD]: """Returns all CSAD instances across all bows in the backyard.""" csads = [bow.csad for bow in self.backyard.bow.values() if bow.csad is not None] - return EquipmentDict(csads) + return self._make_equipment_dict(csads) @property def all_bows(self) -> EquipmentDict[Bow]: