From 808b95571c765a5d4335e60c3faf77efd0763c9f Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:57:04 -0500 Subject: [PATCH 01/61] feat: begin major refactor of codebase - provide abstraction layer within library - allows interacting with omnilogic in a more pythonic way --- pyomnilogic_local/__init__.py | 3 + pyomnilogic_local/_base.py | 61 ++++++++++ pyomnilogic_local/api/__init__.py | 5 + pyomnilogic_local/{ => api}/api.py | 74 ++++++++---- pyomnilogic_local/{ => api}/exceptions.py | 4 - pyomnilogic_local/{ => api}/protocol.py | 6 +- pyomnilogic_local/backyard.py | 48 ++++++++ pyomnilogic_local/bow.py | 5 + pyomnilogic_local/cli/debug/commands.py | 6 +- pyomnilogic_local/cli/get/lights.py | 14 +-- pyomnilogic_local/cli/pcap_utils.py | 2 +- pyomnilogic_local/cli/utils.py | 4 +- pyomnilogic_local/colorlogiclight.py | 3 + pyomnilogic_local/models/__init__.py | 12 ++ pyomnilogic_local/models/exceptions.py | 2 + pyomnilogic_local/models/mspconfig.py | 129 +++++++++++++++++---- pyomnilogic_local/models/telemetry.py | 47 ++++++-- pyomnilogic_local/models/util.py | 35 ------ pyomnilogic_local/omnilogic.py | 68 +++++++++++ pyomnilogic_local/omnitypes.py | 130 +++++++++++++++------- pyomnilogic_local/relay.py | 5 + pyomnilogic_local/sensor.py | 5 + pyomnilogic_local/system.py | 6 + tests/test_protocol.py | 4 +- 24 files changed, 525 insertions(+), 153 deletions(-) create mode 100644 pyomnilogic_local/_base.py create mode 100644 pyomnilogic_local/api/__init__.py rename pyomnilogic_local/{ => api}/api.py (92%) rename pyomnilogic_local/{ => api}/exceptions.py (64%) rename pyomnilogic_local/{ => api}/protocol.py (99%) create mode 100644 pyomnilogic_local/backyard.py create mode 100644 pyomnilogic_local/bow.py create mode 100644 pyomnilogic_local/colorlogiclight.py create mode 100644 pyomnilogic_local/models/exceptions.py delete mode 100644 pyomnilogic_local/models/util.py create mode 100644 pyomnilogic_local/omnilogic.py create mode 100644 pyomnilogic_local/relay.py create mode 100644 pyomnilogic_local/sensor.py create mode 100644 pyomnilogic_local/system.py diff --git a/pyomnilogic_local/__init__.py b/pyomnilogic_local/__init__.py index e69de29..1a8d56d 100644 --- a/pyomnilogic_local/__init__.py +++ b/pyomnilogic_local/__init__.py @@ -0,0 +1,3 @@ +from .omnilogic import OmniLogic + +__all__ = ["OmniLogic"] diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py new file mode 100644 index 0000000..b8de730 --- /dev/null +++ b/pyomnilogic_local/_base.py @@ -0,0 +1,61 @@ +from typing import Any + +from pyomnilogic_local.models import MSPEquipmentType, Telemetry + + +class OmniEquipment: + """Base class for OmniLogic equipment.""" + + def __init__(self, mspconfig: MSPEquipmentType, telemetry: Telemetry | None = None) -> None: + """Initialize the equipment with configuration and telemetry data.""" + # If the Equipment has subdevices, we don't store those as part of this device's config + # They will get parsed and stored as their own equipment instances + try: + self.mspconfig = mspconfig.without_subdevices() + except AttributeError: + self.mspconfig = mspconfig + + if hasattr(self, "telemetry") and telemetry is not None: + self.telemetry = telemetry.get_telem_by_systemid(self.mspconfig.system_id) + + # Populate fields from MSP configuration and telemetry + # This is some moderate magic to avoid having to manually set each field + # The TL;DR is that we loop over all fields defined in the MSPConfig and Telemetry models + # and set the corresponding attributes on this equipment instance. + for field in self.mspconfig.__class__.model_fields: + if getattr(self.mspconfig, field, None) is not None: + setattr(self, field, self._from_mspconfig(field)) + for field in self.mspconfig.__class__.model_computed_fields: + if getattr(self.mspconfig, field, None) is not None: + setattr(self, field, self._from_mspconfig(field)) + if hasattr(self, "telemetry") and self.telemetry is not None: + for field in self.telemetry.__class__.model_fields: + if getattr(self.telemetry, field, None) is not None: + setattr(self, field, self._from_telemetry(field)) + for field in self.telemetry.__class__.model_computed_fields: + if getattr(self.telemetry, field, None) is not None: + setattr(self, field, self._from_telemetry(field)) + + def update_config(self, mspconfig: MSPEquipmentType) -> None: + """Update the configuration data for the equipment.""" + if hasattr(self, "mspconfig"): + self.mspconfig = mspconfig.without_subdevices() + else: + raise NotImplementedError("This equipment does not have MSP configuration.") + + def update_telemetry(self, telemetry: Telemetry) -> None: + """Update the telemetry data for the equipment.""" + if hasattr(self, "telemetry"): + self.telemetry = telemetry.get_telem_by_systemid(self.mspconfig.system_id) + else: + raise NotImplementedError("This equipment does not have telemetry data.") + + def _from_mspconfig(self, attribute: str) -> Any: + """Helper method to get a value from the MSP configuration.""" + return getattr(self.mspconfig, attribute, None) + + def _from_telemetry(self, attribute: str) -> Any: + """Helper method to get a value from the telemetry data.""" + if hasattr(self, "telemetry"): + return getattr(self.telemetry, attribute, None) + return None diff --git a/pyomnilogic_local/api/__init__.py b/pyomnilogic_local/api/__init__.py new file mode 100644 index 0000000..938551e --- /dev/null +++ b/pyomnilogic_local/api/__init__.py @@ -0,0 +1,5 @@ +from .api import OmniLogicAPI + +__all__ = [ + "OmniLogicAPI", +] diff --git a/pyomnilogic_local/api.py b/pyomnilogic_local/api/api.py similarity index 92% rename from pyomnilogic_local/api.py rename to pyomnilogic_local/api/api.py index e63834b..fed0c08 100644 --- a/pyomnilogic_local/api.py +++ b/pyomnilogic_local/api/api.py @@ -6,13 +6,16 @@ import xml.etree.ElementTree as ET from typing import Literal, overload -from .models.filter_diagnostics import FilterDiagnostics -from .models.mspconfig import MSPConfig -from .models.telemetry import Telemetry -from .models.util import to_pydantic -from .omnitypes import ( +from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics +from pyomnilogic_local.models.mspconfig import MSPConfig +from pyomnilogic_local.models.telemetry import Telemetry + +from ..omnitypes import ( ColorLogicBrightness, - ColorLogicShow, + ColorLogicShow25, + ColorLogicShow40, + ColorLogicShowUCL, + ColorLogicShowUCLV2, ColorLogicSpeed, HeaterMode, MessageType, @@ -23,12 +26,12 @@ class OmniLogicAPI: - def __init__(self, controller_ip: str, controller_port: int, response_timeout: float) -> None: + def __init__(self, controller_ip: str, controller_port: int, response_timeout: float = 5.0) -> None: self.controller_ip = controller_ip self.controller_port = controller_port self.response_timeout = response_timeout - self._loop = asyncio.get_running_loop() - self._protocol_factory = OmniLogicProtocol + # self._loop = asyncio.get_running_loop() + # self._protocol_factory = OmniLogicProtocol @overload async def async_send_message(self, message_type: MessageType, message: str | None, need_response: Literal[True]) -> str: ... @@ -61,8 +64,13 @@ async def async_send_message(self, message_type: MessageType, message: str | Non return resp - @to_pydantic(pydantic_type=MSPConfig) - async def async_get_config(self) -> str: + @overload + async def async_get_mspconfig(self, raw: Literal[True]) -> str: ... + @overload + async def async_get_mspconfig(self, raw: Literal[False]) -> MSPConfig: ... + @overload + async def async_get_mspconfig(self) -> MSPConfig: ... + async def async_get_mspconfig(self, raw: bool = False) -> MSPConfig | str: """Retrieve the MSPConfig from the Omni, optionally parse it into a pydantic model. Args: @@ -78,14 +86,21 @@ async def async_get_config(self) -> str: req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") - return await self.async_send_message(MessageType.REQUEST_CONFIGURATION, req_body, True) + resp = await self.async_send_message(MessageType.REQUEST_CONFIGURATION, req_body, True) - @to_pydantic(pydantic_type=FilterDiagnostics) - async def async_get_filter_diagnostics( - self, - pool_id: int, - equipment_id: int, - ) -> str: + if raw: + return resp + return MSPConfig.load_xml(resp) + + @overload + async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: Literal[True]) -> str: ... + @overload + async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: Literal[False]) -> FilterDiagnostics: ... + @overload + async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int) -> FilterDiagnostics: ... + @overload + async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: bool) -> FilterDiagnostics | str: ... + async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: bool = False) -> FilterDiagnostics | str: """Retrieve filter diagnostics from the Omni, optionally parse it into a pydantic model. Args: @@ -108,10 +123,19 @@ async def async_get_filter_diagnostics( req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") - return await self.async_send_message(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body, True) + resp = await self.async_send_message(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body, True) + + if raw: + return resp + return FilterDiagnostics.load_xml(resp) - @to_pydantic(pydantic_type=Telemetry) - async def async_get_telemetry(self) -> str: + @overload + async def async_get_telemetry(self, raw: Literal[True]) -> str: ... + @overload + async def async_get_telemetry(self, raw: Literal[False]) -> Telemetry: ... + @overload + async def async_get_telemetry(self) -> Telemetry: ... + async def async_get_telemetry(self, raw: bool = False) -> Telemetry | str: """Retrieve the current telemetry data from the Omni, optionally parse it into a pydantic model. Returns: @@ -124,7 +148,11 @@ async def async_get_telemetry(self) -> str: req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") - return await self.async_send_message(MessageType.GET_TELEMETRY, req_body, True) + resp = await self.async_send_message(MessageType.GET_TELEMETRY, req_body, True) + + if raw: + return resp + return Telemetry.load_xml(resp) async def async_set_heater( self, @@ -352,7 +380,7 @@ async def async_set_light_show( self, pool_id: int, equipment_id: int, - show: ColorLogicShow, + show: ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2, speed: ColorLogicSpeed = ColorLogicSpeed.ONE_TIMES, brightness: ColorLogicBrightness = ColorLogicBrightness.ONE_HUNDRED_PERCENT, reserved: int = 0, diff --git a/pyomnilogic_local/exceptions.py b/pyomnilogic_local/api/exceptions.py similarity index 64% rename from pyomnilogic_local/exceptions.py rename to pyomnilogic_local/api/exceptions.py index 6802807..4a10da3 100644 --- a/pyomnilogic_local/exceptions.py +++ b/pyomnilogic_local/api/exceptions.py @@ -4,7 +4,3 @@ class OmniLogicException(Exception): class OmniTimeoutException(OmniLogicException): pass - - -class OmniParsingException(OmniLogicException): - pass diff --git a/pyomnilogic_local/protocol.py b/pyomnilogic_local/api/protocol.py similarity index 99% rename from pyomnilogic_local/protocol.py rename to pyomnilogic_local/api/protocol.py index 158604a..234d666 100644 --- a/pyomnilogic_local/protocol.py +++ b/pyomnilogic_local/api/protocol.py @@ -9,9 +9,9 @@ from typing_extensions import Self +from ..models.leadmessage import LeadMessage +from ..omnitypes import ClientType, MessageType from .exceptions import OmniTimeoutException -from .models.leadmessage import LeadMessage -from .omnitypes import ClientType, MessageType _LOGGER = logging.getLogger(__name__) @@ -186,7 +186,7 @@ async def _wait_for_ack(self, ack_id: int) -> None: exc = error_task.result() if isinstance(exc, Exception): raise exc - _LOGGER.error("Unknown error occurred during communication with Omnilogic: %s", exc) + _LOGGER.error("Unknown error occurred during communication with OmniLogic: %s", exc) if data_task in done: message = data_task.result() if message.id == ack_id: diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py new file mode 100644 index 0000000..cba0c86 --- /dev/null +++ b/pyomnilogic_local/backyard.py @@ -0,0 +1,48 @@ +from pyomnilogic_local.models.mspconfig import MSPBackyard +from pyomnilogic_local.models.telemetry import Telemetry + +from ._base import OmniEquipment +from .bow import Bow +from .colorlogiclight import ColorLogicLight +from .relay import Relay +from .sensor import Sensor + + +class Backyard(OmniEquipment): + """Represents the backyard equipment in the OmniLogic system.""" + + bow: list[Bow] = [] + lights: list[ColorLogicLight] = [] + relays: list[Relay] = [] + sensors: list[Sensor] = [] + + def __init__(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + super().__init__(mspconfig, telemetry) + + self._update_bows(mspconfig, telemetry) + self._update_relays(mspconfig, telemetry) + self._update_sensors(mspconfig, telemetry) + + def _update_bows(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + """Update the bows based on the MSP configuration.""" + if mspconfig.bow is None: + self.bow = [] + return + + self.bow = [Bow(bow, telemetry) for bow in mspconfig.bow] + + def _update_relays(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + """Update the relays based on the MSP configuration.""" + if mspconfig.relay is None: + self.relays = [] + return + + self.relays = [Relay(relay, telemetry) for relay in mspconfig.relay] + + def _update_sensors(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + """Update the sensors, bows, lights, and relays based on the MSP configuration.""" + if mspconfig.sensor is None: + self.sensors = [] + return + + self.sensors = [Sensor(sensor, telemetry) for sensor in mspconfig.sensor] diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py new file mode 100644 index 0000000..b48b238 --- /dev/null +++ b/pyomnilogic_local/bow.py @@ -0,0 +1,5 @@ +from pyomnilogic_local._base import OmniEquipment + + +class Bow(OmniEquipment): + """Represents a bow in the OmniLogic system.""" diff --git a/pyomnilogic_local/cli/debug/commands.py b/pyomnilogic_local/cli/debug/commands.py index 9266c0d..d0684e8 100644 --- a/pyomnilogic_local/cli/debug/commands.py +++ b/pyomnilogic_local/cli/debug/commands.py @@ -5,7 +5,7 @@ import click -from pyomnilogic_local.api import OmniLogicAPI +from pyomnilogic_local.api.api import OmniLogicAPI from pyomnilogic_local.cli import ensure_connection from pyomnilogic_local.cli.pcap_utils import parse_pcap_file, process_pcap_messages from pyomnilogic_local.cli.utils import async_get_filter_diagnostics @@ -39,7 +39,7 @@ def get_mspconfig(ctx: click.Context) -> None: """ ensure_connection(ctx) omni: OmniLogicAPI = ctx.obj["OMNI"] - mspconfig = asyncio.run(omni.async_get_config(raw=ctx.obj["RAW"])) + mspconfig = asyncio.run(omni.async_get_mspconfig(raw=ctx.obj["RAW"])) click.echo(mspconfig) @@ -104,7 +104,7 @@ def get_filter_diagnostics(ctx: click.Context, pool_id: int, filter_id: int) -> @click.argument("pcap_file", type=click.Path(exists=True, path_type=Path)) @click.pass_context def parse_pcap(ctx: click.Context, pcap_file: Path) -> None: - """Parse a PCAP file and reconstruct Omnilogic protocol communication. + """Parse a PCAP file and reconstruct OmniLogic protocol communication. Analyzes network packet captures to decode OmniLogic protocol messages. Automatically reassembles multi-part messages (LeadMessage + BlockMessages) diff --git a/pyomnilogic_local/cli/get/lights.py b/pyomnilogic_local/cli/get/lights.py index a7eecd2..7dfd20c 100644 --- a/pyomnilogic_local/cli/get/lights.py +++ b/pyomnilogic_local/cli/get/lights.py @@ -1,7 +1,7 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" -from typing import Any +from typing import Any, cast import click @@ -11,12 +11,11 @@ ) from pyomnilogic_local.models.telemetry import ( Telemetry, - TelemetryType, + TelemetryColorLogicLight, ) from pyomnilogic_local.omnitypes import ( ColorLogicBrightness, ColorLogicPowerState, - ColorLogicShow, ColorLogicSpeed, ) @@ -41,7 +40,7 @@ def lights(ctx: click.Context) -> None: if mspconfig.backyard.colorlogic_light: for light in mspconfig.backyard.colorlogic_light: lights_found = True - _print_light_info(light, telemetry.get_telem_by_systemid(light.system_id)) + _print_light_info(light, cast(TelemetryColorLogicLight, telemetry.get_telem_by_systemid(light.system_id))) # Check for lights in Bodies of Water if mspconfig.backyard.bow: @@ -49,13 +48,13 @@ def lights(ctx: click.Context) -> None: if bow.colorlogic_light: for cl_light in bow.colorlogic_light: lights_found = True - _print_light_info(cl_light, telemetry.get_telem_by_systemid(cl_light.system_id)) + _print_light_info(cl_light, cast(TelemetryColorLogicLight, telemetry.get_telem_by_systemid(cl_light.system_id))) if not lights_found: click.echo("No ColorLogic lights found in the system configuration.") -def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryType | None) -> None: +def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryColorLogicLight | None) -> None: """Format and print light information in a nice table format. Args: @@ -67,6 +66,7 @@ def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryType | None click.echo("=" * 60) light_data: dict[Any, Any] = {**dict(light), **dict(telemetry)} if telemetry else dict(light) + for attr_name, value in light_data.items(): if attr_name == "brightness": value = ColorLogicBrightness(value).pretty() @@ -74,7 +74,7 @@ def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryType | None show_names = [show.pretty() if hasattr(show, "pretty") else str(show) for show in value] value = ", ".join(show_names) if show_names else "None" elif attr_name == "show" and value is not None: - value = ColorLogicShow(value).pretty() + value = telemetry.show_name(light.type, light.v2_active, True) if telemetry else str(value) elif attr_name == "speed": value = ColorLogicSpeed(value).pretty() elif attr_name == "state": diff --git a/pyomnilogic_local/cli/pcap_utils.py b/pyomnilogic_local/cli/pcap_utils.py index 15696e9..eec688e 100644 --- a/pyomnilogic_local/cli/pcap_utils.py +++ b/pyomnilogic_local/cli/pcap_utils.py @@ -14,9 +14,9 @@ from scapy.packet import Packet from scapy.utils import rdpcap +from pyomnilogic_local.api.protocol import OmniLogicMessage from pyomnilogic_local.models.leadmessage import LeadMessage from pyomnilogic_local.omnitypes import MessageType -from pyomnilogic_local.protocol import OmniLogicMessage def parse_pcap_file(pcap_path: str) -> Any: diff --git a/pyomnilogic_local/cli/utils.py b/pyomnilogic_local/cli/utils.py index c8e880a..1baee1a 100644 --- a/pyomnilogic_local/cli/utils.py +++ b/pyomnilogic_local/cli/utils.py @@ -9,7 +9,7 @@ import click -from pyomnilogic_local.api import OmniLogicAPI +from pyomnilogic_local.api.api import OmniLogicAPI from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics from pyomnilogic_local.models.mspconfig import MSPConfig from pyomnilogic_local.models.telemetry import Telemetry @@ -40,7 +40,7 @@ async def fetch_startup_data(omni: OmniLogicAPI) -> tuple[MSPConfig, Telemetry]: RuntimeError: If unable to fetch configuration or telemetry from controller """ try: - mspconfig = await omni.async_get_config() + mspconfig = await omni.async_get_mspconfig() telemetry = await omni.async_get_telemetry() except Exception as exc: raise RuntimeError(f"[ERROR] Failed to fetch config or telemetry from controller: {exc}") from exc diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py new file mode 100644 index 0000000..508dd06 --- /dev/null +++ b/pyomnilogic_local/colorlogiclight.py @@ -0,0 +1,3 @@ +class ColorLogicLight: + + pass diff --git a/pyomnilogic_local/models/__init__.py b/pyomnilogic_local/models/__init__.py index e69de29..efd21ba 100644 --- a/pyomnilogic_local/models/__init__.py +++ b/pyomnilogic_local/models/__init__.py @@ -0,0 +1,12 @@ +from .filter_diagnostics import FilterDiagnostics +from .mspconfig import MSPConfig, MSPConfigType, MSPEquipmentType +from .telemetry import Telemetry, TelemetryType + +__all__ = [ + "MSPConfig", + "MSPConfigType", + "MSPEquipmentType", + "Telemetry", + "TelemetryType", + "FilterDiagnostics", +] diff --git a/pyomnilogic_local/models/exceptions.py b/pyomnilogic_local/models/exceptions.py new file mode 100644 index 0000000..82a90ae --- /dev/null +++ b/pyomnilogic_local/models/exceptions.py @@ -0,0 +1,2 @@ +class OmniParsingException(Exception): + pass diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 8b98546..0a3ad64 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -2,23 +2,32 @@ import logging import sys -from typing import Any, Literal, TypeAlias +from typing import Any, ClassVar, Literal if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + model_validator, +) from xmltodict import parse as xml_parse -from ..exceptions import OmniParsingException from ..omnitypes import ( BodyOfWaterType, ChlorinatorCellType, ChlorinatorDispenserType, ColorLogicLightType, ColorLogicShow, + ColorLogicShow25, + ColorLogicShow40, + ColorLogicShowUCL, + ColorLogicShowUCLV2, CSADType, FilterType, HeaterType, @@ -30,6 +39,7 @@ SensorType, SensorUnits, ) +from .exceptions import OmniParsingException _LOGGER = logging.getLogger(__name__) @@ -43,8 +53,7 @@ class OmniBase(BaseModel): bow_id: int | None = None def without_subdevices(self) -> Self: - data = self.model_dump(exclude=self._sub_devices, round_trip=True) - data = {**data, **{}} + data = self.model_dump(exclude=self._sub_devices, round_trip=True, by_alias=True) copied = self.model_validate(data) _LOGGER.debug("without_subdevices: original=%s, copied=%s", self, copied) return copied @@ -68,37 +77,67 @@ def propagate_bow_id(self, bow_id: int | None) -> None: elif subdevice is not None: subdevice.propagate_bow_id(bow_id) + _YES_NO_FIELDS: ClassVar[set[str]] = set() + + @model_validator(mode="before") + @classmethod + def convert_yes_no_to_bool(cls, data: Any) -> Any: + # Check if data is a dictionary (common when loading from XML/JSON) + if not isinstance(data, dict): + return data + + for key in cls._YES_NO_FIELDS: + raw_value = data.get(key) + + if isinstance(raw_value, str): + lower_value = raw_value.lower() + + if lower_value == "yes": + data[key] = True + elif lower_value == "no": + data[key] = False + + return data + class MSPSystem(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.SYSTEM + vsp_speed_format: Literal["RPM", "Percent"] = Field(alias="Msp-Vsp-Speed-Format") units: Literal["Standard", "Metric"] = Field(alias="Units") class MSPSensor(OmniBase): omni_type: OmniType = OmniType.SENSOR + type: SensorType | str = Field(alias="Type") units: SensorUnits | str = Field(alias="Units") class MSPFilter(OmniBase): + _YES_NO_FIELDS = {"priming_enabled"} + omni_type: OmniType = OmniType.FILTER + type: FilterType | str = Field(alias="Filter-Type") max_percent: int = Field(alias="Max-Pump-Speed") min_percent: int = Field(alias="Min-Pump-Speed") max_rpm: int = Field(alias="Max-Pump-RPM") min_rpm: int = Field(alias="Min-Pump-RPM") # We should figure out how to coerce this field into a True/False - priming_enabled: Literal["yes", "no"] = Field(alias="Priming-Enabled") + priming_enabled: bool = Field(alias="Priming-Enabled") low_speed: int = Field(alias="Vsp-Low-Pump-Speed") medium_speed: int = Field(alias="Vsp-Medium-Pump-Speed") high_speed: int = Field(alias="Vsp-High-Pump-Speed") class MSPPump(OmniBase): + _YES_NO_FIELDS = {"priming_enabled"} + omni_type: OmniType = OmniType.PUMP + type: PumpType | str = Field(alias="Type") function: PumpFunction | str = Field(alias="Function") max_percent: int = Field(alias="Max-Pump-Speed") @@ -106,7 +145,7 @@ class MSPPump(OmniBase): max_rpm: int = Field(alias="Max-Pump-RPM") min_rpm: int = Field(alias="Min-Pump-RPM") # We should figure out how to coerce this field into a True/False - priming_enabled: Literal["yes", "no"] = Field(alias="Priming-Enabled") + priming_enabled: bool = Field(alias="Priming-Enabled") low_speed: int = Field(alias="Vsp-Low-Pump-Speed") medium_speed: int = Field(alias="Vsp-Medium-Pump-Speed") high_speed: int = Field(alias="Vsp-High-Pump-Speed") @@ -114,26 +153,32 @@ class MSPPump(OmniBase): class MSPRelay(OmniBase): omni_type: OmniType = OmniType.RELAY + type: RelayType | str = Field(alias="Type") function: RelayFunction | str = Field(alias="Function") class MSPHeaterEquip(OmniBase): + _YES_NO_FIELDS = {"enabled", "supports_cooling"} + omni_type: OmniType = OmniType.HEATER_EQUIP + type: Literal["PET_HEATER"] = Field(alias="Type") heater_type: HeaterType | str = Field(alias="Heater-Type") - enabled: Literal["yes", "no"] = Field(alias="Enabled") + enabled: bool = Field(alias="Enabled") min_filter_speed: int = Field(alias="Min-Speed-For-Operation") sensor_id: int = Field(alias="Sensor-System-Id") - supports_cooling: Literal["yes", "no"] | None = Field(alias="SupportsCooling", default=None) + supports_cooling: bool | None = Field(alias="SupportsCooling", default=None) # This is the entry for the VirtualHeater, it does not use OmniBase because it has no name attribute class MSPVirtualHeater(OmniBase): _sub_devices = {"heater_equipment"} + _YES_NO_FIELDS = {"enabled"} omni_type: OmniType = OmniType.VIRT_HEATER - enabled: Literal["yes", "no"] = Field(alias="Enabled") + + enabled: bool = Field(alias="Enabled") set_point: int = Field(alias="Current-Set-Point") solar_set_point: int | None = Field(alias="SolarSetPoint", default=None) max_temp: int = Field(alias="Max-Settable-Water-Temp") @@ -150,15 +195,20 @@ def __init__(self, **data: Any) -> None: class MSPChlorinatorEquip(OmniBase): + _YES_NO_FIELDS = {"enabled"} + omni_type: OmniType = OmniType.CHLORINATOR_EQUIP - enabled: Literal["yes", "no"] = Field(alias="Enabled") + + enabled: bool = Field(alias="Enabled") class MSPChlorinator(OmniBase): _sub_devices = {"chlorinator_equipment"} + _YES_NO_FIELDS = {"enabled"} omni_type: OmniType = OmniType.CHLORINATOR - enabled: Literal["yes", "no"] = Field(alias="Enabled") + + enabled: bool = Field(alias="Enabled") timed_percent: int = Field(alias="Timed-Percent") superchlor_timeout: int = Field(alias="SuperChlor-Timeout") orp_timeout: int = Field(alias="ORP-Timeout") @@ -178,8 +228,11 @@ def __init__(self, **data: Any) -> None: class MSPCSAD(OmniBase): + _YES_NO_FIELDS = {"enabled"} + omni_type: OmniType = OmniType.CSAD - enabled: Literal["yes", "no"] = Field(alias="Enabled") + + enabled: bool = Field(alias="Enabled") type: CSADType | str = Field(alias="Type") target_value: float = Field(alias="TargetValue") calibration_value: float = Field(alias="CalibrationValue") @@ -194,22 +247,40 @@ class MSPCSAD(OmniBase): class MSPColorLogicLight(OmniBase): + _YES_NO_FIELDS = {"v2_active"} + omni_type: OmniType = OmniType.CL_LIGHT - type: ColorLogicLightType | str = Field(alias="Type") - v2_active: Literal["yes", "no"] | None = Field(alias="V2-Active", default=None) + + type: ColorLogicLightType = Field(alias="Type") + v2_active: bool = Field(alias="V2-Active", default=False) effects: list[ColorLogicShow] | None = None def __init__(self, **data: Any) -> None: super().__init__(**data) - self.effects = list(ColorLogicShow) if self.v2_active == "yes" else [show for show in ColorLogicShow if show.value <= 16] + + # Get the available light shows depending on the light type. + match self.type: + case ColorLogicLightType.TWO_FIVE: + self.effects = list(ColorLogicShow25) + case ColorLogicLightType.FOUR_ZERO: + self.effects = list(ColorLogicShow40) + case ColorLogicLightType.UCL: + if self.v2_active: + self.effects = list(ColorLogicShowUCLV2) + else: + self.effects = list(ColorLogicShowUCL) + + # self.effects = list(ColorLogicShow) if self.v2_active == "yes" else [show for show in ColorLogicShow if show.value <= 16] class MSPBoW(OmniBase): _sub_devices = {"filter", "relay", "heater", "sensor", "colorlogic_light", "pump", "chlorinator", "csad"} + _YES_NO_FIELDS = {"supports_spillover"} omni_type: OmniType = OmniType.BOW + type: BodyOfWaterType | str = Field(alias="Type") - supports_spillover: Literal["yes", "no"] = Field(alias="Supports-Spillover") + supports_spillover: bool = Field(alias="Supports-Spillover", default=False) filter: list[MSPFilter] | None = Field(alias="Filter", default=None) relay: list[MSPRelay] | None = Field(alias="Relay", default=None) heater: MSPVirtualHeater | None = Field(alias="Heater", default=None) @@ -230,24 +301,40 @@ class MSPBackyard(OmniBase): _sub_devices = {"sensor", "bow", "colorlogic_light", "relay"} omni_type: OmniType = OmniType.BACKYARD - sensor: list[MSPSensor] | None = Field(alias="Sensor", default=None) + bow: list[MSPBoW] | None = Field(alias="Body-of-water", default=None) - relay: list[MSPRelay] | None = Field(alias="Relay", default=None) colorlogic_light: list[MSPColorLogicLight] | None = Field(alias="ColorLogic-Light", default=None) + relay: list[MSPRelay] | None = Field(alias="Relay", default=None) + sensor: list[MSPSensor] | None = Field(alias="Sensor", default=None) class MSPSchedule(OmniBase): omni_type: OmniType = OmniType.SCHEDULE + system_id: int = Field(alias="schedule-system-id") bow_id: int | None = Field(alias="bow-system-id", default=None) equipment_id: int = Field(alias="equipment-id") enabled: bool = Field() -MSPConfigType: TypeAlias = ( - MSPSystem | MSPSchedule | MSPBackyard | MSPBoW | MSPVirtualHeater | MSPHeaterEquip | MSPRelay | MSPFilter | MSPSensor +type MSPEquipmentType = ( + MSPSchedule + | MSPBackyard + | MSPBoW + | MSPVirtualHeater + | MSPHeaterEquip + | MSPRelay + | MSPFilter + | MSPSensor + | MSPPump + | MSPChlorinator + | MSPChlorinatorEquip + | MSPCSAD + | MSPColorLogicLight ) +type MSPConfigType = (MSPSystem | MSPEquipmentType) + class MSPConfig(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index ad21385..7f49778 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -1,11 +1,10 @@ from __future__ import annotations -from typing import Any, SupportsInt, TypeAlias, TypeVar, cast, overload +from typing import Any, SupportsInt, TypeVar, cast, overload -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError, computed_field from xmltodict import parse as xml_parse -from ..exceptions import OmniParsingException from ..omnitypes import ( BackyardState, ChlorinatorAlert, @@ -13,8 +12,12 @@ ChlorinatorOperatingMode, ChlorinatorStatus, ColorLogicBrightness, + ColorLogicLightType, ColorLogicPowerState, - ColorLogicShow, + ColorLogicShow25, + ColorLogicShow40, + ColorLogicShowUCL, + ColorLogicShowUCLV2, ColorLogicSpeed, CSADMode, FilterState, @@ -28,6 +31,7 @@ RelayWhyOn, ValveActuatorState, ) +from .exceptions import OmniParsingException # Example telemetry XML data: # @@ -52,7 +56,7 @@ class TelemetryBackyard(BaseModel): omni_type: OmniType = OmniType.BACKYARD system_id: int = Field(alias="@systemId") status_version: int = Field(alias="@statusVersion") - air_temp: int = Field(alias="@airTemp") + air_temp: int | None = Field(alias="@airTemp") state: BackyardState | int = Field(alias="@state") # The below two fields are only available for telemetry with a status_version >= 11 config_checksum: int | None = Field(alias="@ConfigChksum", default=None) @@ -84,7 +88,7 @@ class TelemetryChlorinator(BaseModel): operating_mode: ChlorinatorOperatingMode | int = Field(alias="@operatingMode") enable: bool = Field(alias="@enable") - @property + @computed_field def status(self) -> list[str]: """Decode status bitmask into a list of active status flag names. @@ -97,7 +101,7 @@ def status(self) -> list[str]: """ return [flag.name for flag in ChlorinatorStatus if self.status_raw & flag.value and flag.name is not None] - @property + @computed_field def alerts(self) -> list[str]: """Decode chlrAlert bitmask into a list of active alert flag names. @@ -127,7 +131,7 @@ def alerts(self) -> list[str]: return final_flags - @property + @computed_field def errors(self) -> list[str]: """Decode chlrError bitmask into a list of active error flag names. @@ -157,7 +161,7 @@ def errors(self) -> list[str]: return final_flags - @property + @computed_field def active(self) -> bool: """Check if the chlorinator is actively generating chlorine. @@ -184,11 +188,31 @@ class TelemetryColorLogicLight(BaseModel): omni_type: OmniType = OmniType.CL_LIGHT system_id: int = Field(alias="@systemId") state: ColorLogicPowerState | int = Field(alias="@lightState") - show: ColorLogicShow | int = Field(alias="@currentShow") + show: int = Field(alias="@currentShow") speed: ColorLogicSpeed | int = Field(alias="@speed") brightness: ColorLogicBrightness | int = Field(alias="@brightness") special_effect: int = Field(alias="@specialEffect") + def show_name( + self, model: ColorLogicLightType, v2: bool, pretty: bool = False + ) -> ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2 | int: + """Get the current light show depending on the light type. + + Returns: + ColorLogicShowUCL enum member corresponding to the current show, + or None if the show value is invalid. + """ + match model: + case ColorLogicLightType.TWO_FIVE: + return ColorLogicShow25(self.show) + case ColorLogicLightType.FOUR_ZERO: + return ColorLogicShow40(self.show) + case ColorLogicLightType.UCL: + if v2: + return ColorLogicShowUCLV2(self.show) + return ColorLogicShowUCL(self.show) + return self.show # Return raw int if type is unknown + class TelemetryFilter(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -267,10 +291,11 @@ class TelemetryVirtualHeater(BaseModel): why_on: int = Field(alias="@whyHeaterIsOn") -TelemetryType: TypeAlias = ( +type TelemetryType = ( TelemetryBackyard | TelemetryBoW | TelemetryChlorinator + | TelemetryCSAD | TelemetryColorLogicLight | TelemetryFilter | TelemetryGroup diff --git a/pyomnilogic_local/models/util.py b/pyomnilogic_local/models/util.py deleted file mode 100644 index 3163c69..0000000 --- a/pyomnilogic_local/models/util.py +++ /dev/null @@ -1,35 +0,0 @@ -import logging -from collections.abc import Awaitable, Callable -from typing import Any, Literal, TypeVar, cast, overload - -from .filter_diagnostics import FilterDiagnostics -from .mspconfig import MSPConfig -from .telemetry import Telemetry - -_LOGGER = logging.getLogger(__name__) - - -F = TypeVar("F", bound=Callable[..., Awaitable[str]]) - - -def to_pydantic( - pydantic_type: type[Telemetry | MSPConfig | FilterDiagnostics], -) -> Callable[..., Any]: - def inner(func: F, *args: Any, **kwargs: Any) -> F: - """Wrap an API function that returns XML and parse it into a Pydantic model""" - - @overload - async def wrapper(*args: Any, raw: Literal[True], **kwargs: Any) -> str: ... - - @overload - async def wrapper(*args: Any, raw: Literal[False], **kwargs: Any) -> Telemetry | MSPConfig | FilterDiagnostics: ... - - async def wrapper(*args: Any, raw: bool = False, **kwargs: Any) -> Telemetry | MSPConfig | FilterDiagnostics | str: - resp_body = await func(*args, **kwargs) - if raw: - return resp_body - return pydantic_type.load_xml(resp_body) - - return cast(F, wrapper) - - return inner diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py new file mode 100644 index 0000000..679298c --- /dev/null +++ b/pyomnilogic_local/omnilogic.py @@ -0,0 +1,68 @@ +import logging + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.api import OmniLogicAPI +from pyomnilogic_local.backyard import Backyard +from pyomnilogic_local.models import MSPConfig, Telemetry +from pyomnilogic_local.omnitypes import OmniType +from pyomnilogic_local.system import System + +_LOGGER = logging.getLogger(__name__) + + +class OmniLogic: + poll_mspconfig: bool = True + poll_telemetry: bool = True + + mspconfig: MSPConfig + telemetry: Telemetry + + system: System + backyard: Backyard + + equipment: dict[int, OmniEquipment] = {} + + def __init__(self, host: str, port: int = 10444) -> None: + self.host = host + self.port = port + + self._api = OmniLogicAPI(host, port) + + async def refresh(self) -> None: + """Refresh the data from the OmniLogic controller.""" + if self.poll_mspconfig: + self.mspconfig = await self._api.async_get_mspconfig() + if self.poll_telemetry: + self.telemetry = await self._api.async_get_telemetry() + + self._update_equipment() + + async def refresh_mspconfig(self) -> None: + """Refresh only the MSPConfig data from the OmniLogic controller.""" + self.mspconfig = await self._api.async_get_mspconfig() + self._update_equipment() + + async def refresh_telemetry(self) -> None: + """Refresh only the Telemetry data from the OmniLogic controller.""" + self.telemetry = await self._api.async_get_telemetry() + self._update_equipment() + + def _update_equipment(self) -> None: + """Update equipment objects based on the latest MSPConfig and Telemetry data.""" + + _LOGGER.debug("Updating ColorLogic Light equipment data") + + if not hasattr(self, "mspconfig") or self.mspconfig is None: + _LOGGER.debug("No MSPConfig data available; skipping equipment update") + return + + for _, equipment_mspconfig in self.mspconfig: + if equipment_mspconfig.omni_type == OmniType.SYSTEM: + self.system = System(equipment_mspconfig) + if equipment_mspconfig.omni_type == OmniType.BACKYARD: + self.backyard = Backyard(equipment_mspconfig, self.telemetry) + + # if hasattr(self, "mspconfig") and self.mspconfig is not None: + # _LOGGER.debug(self.mspconfig) + # if hasattr(self, "telemetry") and self.telemetry is not None: + # _LOGGER.debug(self.telemetry) diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index 24cae6d..4697757 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -1,4 +1,4 @@ -from enum import Enum, Flag, IntEnum +from enum import Enum, Flag, IntEnum, StrEnum from .util import PrettyEnum @@ -32,13 +32,13 @@ class MessageType(Enum): MSP_BLOCKMESSAGE = 1999 -class ClientType(Enum): +class ClientType(IntEnum, PrettyEnum): XML = 0 SIMPLE = 1 OMNI = 3 -class OmniType(str, Enum): +class OmniType(StrEnum): BACKYARD = "Backyard" BOW = "BodyOfWater" BOW_MSP = "Body-of-water" @@ -61,12 +61,9 @@ class OmniType(str, Enum): VALVE_ACTUATOR = "ValveActuator" VIRT_HEATER = "VirtualHeater" - def __str__(self) -> str: - return OmniType[self.name].value - # Backyard/BoW -class BackyardState(PrettyEnum): +class BackyardState(IntEnum, PrettyEnum): OFF = 0 ON = 1 SERVICE_MODE = 2 @@ -74,12 +71,12 @@ class BackyardState(PrettyEnum): TIMED_SERVICE_MODE = 4 -class BodyOfWaterState(PrettyEnum): +class BodyOfWaterState(IntEnum, PrettyEnum): NO_FLOW = 0 FLOW = 1 -class BodyOfWaterType(str, PrettyEnum): +class BodyOfWaterType(StrEnum, PrettyEnum): POOL = "BOW_POOL" SPA = "BOW_SPA" @@ -145,20 +142,20 @@ class ChlorinatorError(Flag): AQUARITE_PCB_ERROR = 1 << 14 -class ChlorinatorOperatingMode(IntEnum): +class ChlorinatorOperatingMode(IntEnum, PrettyEnum): DISABLED = 0 TIMED = 1 ORP_AUTO = 2 ORP_TIMED_RW = 3 # CSAD in ORP mode experienced condition that prevents ORP operation -class ChlorinatorDispenserType(str, PrettyEnum): +class ChlorinatorDispenserType(StrEnum, PrettyEnum): SALT = "SALT_DISPENSING" LIQUID = "LIQUID_DISPENSING" TABLET = "TABLET_DISPENSING" -class ChlorinatorCellType(PrettyEnum): +class ChlorinatorCellType(StrEnum, PrettyEnum): UNKNOWN = "CELL_TYPE_UNKNOWN" T3 = "CELL_TYPE_T3" T5 = "CELL_TYPE_T5" @@ -176,7 +173,7 @@ def __int__(self) -> int: return ChlorinatorCellInt[self.name].value -class ChlorinatorCellInt(IntEnum): +class ChlorinatorCellInt(IntEnum, PrettyEnum): UNKNOWN = 0 T3 = 1 T5 = 2 @@ -191,7 +188,7 @@ class ChlorinatorCellInt(IntEnum): # Lights -class ColorLogicSpeed(PrettyEnum): +class ColorLogicSpeed(IntEnum, PrettyEnum): ONE_SIXTEENTH = 0 ONE_EIGHTH = 1 ONE_QUARTER = 2 @@ -203,7 +200,7 @@ class ColorLogicSpeed(PrettyEnum): SIXTEEN_TIMES = 8 -class ColorLogicBrightness(PrettyEnum): +class ColorLogicBrightness(IntEnum, PrettyEnum): TWENTY_PERCENT = 0 FOURTY_PERCENT = 1 SIXTY_PERCENT = 2 @@ -211,7 +208,60 @@ class ColorLogicBrightness(PrettyEnum): ONE_HUNDRED_PERCENT = 4 -class ColorLogicShow(PrettyEnum): +type ColorLogicShow = ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2 + + +class ColorLogicShow25(IntEnum, PrettyEnum): + VOODOO_LOUNGE = 0 + DEEP_BLUE_SEA = 1 + AFTERNOON_SKY = 2 + EMERALD = 3 + SANGRIA = 4 + CLOUD_WHITE = 5 + TWILIGHT = 6 + TRANQUILITY = 7 + GEMSTONE = 8 + USA = 9 + MARDI_GRAS = 10 + COOL_CABARET = 11 + + +class ColorLogicShow40(IntEnum, PrettyEnum): + VOODOO_LOUNGE = 0 + DEEP_BLUE_SEA = 1 + AFTERNOON_SKY = 2 + EMERALD = 3 + SANGRIA = 4 + CLOUD_WHITE = 5 + TWILIGHT = 6 + TRANQUILITY = 7 + GEMSTONE = 8 + USA = 9 + MARDI_GRAS = 10 + COOL_CABARET = 11 + + +class ColorLogicShowUCL(IntEnum, PrettyEnum): + VOODOO_LOUNGE = 0 + DEEP_BLUE_SEA = 1 + ROYAL_BLUE = 2 + AFTERNOON_SKY = 3 + AQUA_GREEN = 4 + EMERALD = 5 + CLOUD_WHITE = 6 + WARM_RED = 7 + FLAMINGO = 8 + VIVID_VIOLET = 9 + SANGRIA = 10 + TWILIGHT = 11 + TRANQUILITY = 12 + GEMSTONE = 13 + USA = 14 + MARDI_GRAS = 15 + COOL_CABARET = 16 + + +class ColorLogicShowUCLV2(IntEnum, PrettyEnum): VOODOO_LOUNGE = 0 DEEP_BLUE_SEA = 1 ROYAL_BLUE = 2 @@ -241,11 +291,8 @@ class ColorLogicShow(PrettyEnum): WARM_WHITE = 25 BRIGHT_YELLOW = 26 - def __str__(self) -> str: - return self.name - -class ColorLogicPowerState(PrettyEnum): +class ColorLogicPowerState(IntEnum, PrettyEnum): OFF = 0 POWERING_OFF = 1 CHANGING_SHOW = 3 @@ -254,27 +301,28 @@ class ColorLogicPowerState(PrettyEnum): COOLDOWN = 7 -class ColorLogicLightType(str, PrettyEnum): +class ColorLogicLightType(StrEnum, PrettyEnum): UCL = "COLOR_LOGIC_UCL" FOUR_ZERO = "COLOR_LOGIC_4_0" TWO_FIVE = "COLOR_LOGIC_2_5" + SAM = "COLOR_LOGIC_SAM" def __str__(self) -> str: return ColorLogicLightType[self.name].value -class CSADType(str, PrettyEnum): +class CSADType(StrEnum, PrettyEnum): ACID = "ACID" CO2 = "CO2" # Chemistry Sense and Dispense -class CSADStatus(PrettyEnum): +class CSADStatus(IntEnum, PrettyEnum): NOT_DISPENSING = 0 DISPENSING = 1 -class CSADMode(PrettyEnum): +class CSADMode(IntEnum, PrettyEnum): OFF = 0 AUTO = 1 FORCE_ON = 2 @@ -283,7 +331,7 @@ class CSADMode(PrettyEnum): # Filters -class FilterState(PrettyEnum): +class FilterState(IntEnum, PrettyEnum): OFF = 0 ON = 1 PRIMING = 2 @@ -298,13 +346,13 @@ class FilterState(PrettyEnum): FILTER_WAITING_TURN_OFF = 11 -class FilterType(str, PrettyEnum): +class FilterType(StrEnum, PrettyEnum): VARIABLE_SPEED = "FMT_VARIABLE_SPEED_PUMP" DUAL_SPEED = "FMT_DUAL_SPEED" SINGLE_SPEED = "FMT_SINGLE_SPEED" -class FilterValvePosition(PrettyEnum): +class FilterValvePosition(IntEnum, PrettyEnum): POOL_ONLY = 1 SPA_ONLY = 2 SPILLOVER = 3 @@ -312,7 +360,7 @@ class FilterValvePosition(PrettyEnum): HIGH_PRIO_HEAT = 5 -class FilterWhyOn(PrettyEnum): +class FilterWhyOn(IntEnum, PrettyEnum): OFF = 0 NO_WATER_FLOW = 1 COOLDOWN = 2 @@ -335,13 +383,13 @@ class FilterWhyOn(PrettyEnum): # Heaters -class HeaterState(PrettyEnum): +class HeaterState(IntEnum, PrettyEnum): OFF = 0 ON = 1 PAUSE = 2 -class HeaterType(str, PrettyEnum): +class HeaterType(StrEnum, PrettyEnum): GAS = "HTR_GAS" HEAT_PUMP = "HTR_HEAT_PUMP" SOLAR = "HTR_SOLAR" @@ -350,25 +398,25 @@ class HeaterType(str, PrettyEnum): SMART = "HTR_SMART" -class HeaterMode(PrettyEnum): +class HeaterMode(IntEnum, PrettyEnum): HEAT = 0 COOL = 1 AUTO = 2 # Pumps -class PumpState(PrettyEnum): +class PumpState(IntEnum, PrettyEnum): OFF = 0 ON = 1 -class PumpType(str, PrettyEnum): +class PumpType(StrEnum, PrettyEnum): SINGLE_SPEED = "PMP_SINGLE_SPEED" DUAL_SPEED = "PMP_DUAL_SPEED" VARIABLE_SPEED = "PMP_VARIABLE_SPEED_PUMP" -class PumpFunction(str, PrettyEnum): +class PumpFunction(StrEnum, PrettyEnum): PUMP = "PMP_PUMP" WATER_FEATURE = "PMP_WATER_FEATURE" CLEANER = "PMP_CLEANER" @@ -386,7 +434,7 @@ class PumpFunction(str, PrettyEnum): # Relays -class RelayFunction(str, PrettyEnum): +class RelayFunction(StrEnum, PrettyEnum): WATER_FEATURE = "RLY_WATER_FEATURE" LIGHT = "RLY_LIGHT" BACKYARD_LIGHT = "RLY_BACKYARD_LIGHT" @@ -406,18 +454,18 @@ class RelayFunction(str, PrettyEnum): CLEANER_IN_FLOOR = "RLY_CLEANER_IN_FLOOR" -class RelayState(PrettyEnum): +class RelayState(IntEnum, PrettyEnum): OFF = 0 ON = 1 -class RelayType(str, PrettyEnum): +class RelayType(StrEnum, PrettyEnum): VALVE_ACTUATOR = "RLY_VALVE_ACTUATOR" HIGH_VOLTAGE = "RLY_HIGH_VOLTAGE_RELAY" LOW_VOLTAGE = "RLY_LOW_VOLTAGE_RELAY" -class RelayWhyOn(PrettyEnum): +class RelayWhyOn(IntEnum, PrettyEnum): OFF = 0 ON = 1 FREEZE_PROTECT = 2 @@ -427,7 +475,7 @@ class RelayWhyOn(PrettyEnum): # Sensors -class SensorType(str, PrettyEnum): +class SensorType(StrEnum, PrettyEnum): AIR_TEMP = "SENSOR_AIR_TEMP" SOLAR_TEMP = "SENSOR_SOLAR_TEMP" WATER_TEMP = "SENSOR_WATER_TEMP" @@ -436,7 +484,7 @@ class SensorType(str, PrettyEnum): EXT_INPUT = "SENSOR_EXT_INPUT" -class SensorUnits(str, PrettyEnum): +class SensorUnits(StrEnum, PrettyEnum): FAHRENHEIT = "UNITS_FAHRENHEIT" CELSIUS = "UNITS_CELSIUS" PPM = "UNITS_PPM" @@ -447,6 +495,6 @@ class SensorUnits(str, PrettyEnum): # Valve Actuators -class ValveActuatorState(PrettyEnum): +class ValveActuatorState(IntEnum, PrettyEnum): OFF = 0 ON = 1 diff --git a/pyomnilogic_local/relay.py b/pyomnilogic_local/relay.py new file mode 100644 index 0000000..48fe4f0 --- /dev/null +++ b/pyomnilogic_local/relay.py @@ -0,0 +1,5 @@ +from pyomnilogic_local._base import OmniEquipment + + +class Relay(OmniEquipment): + """Represents a relay in the OmniLogic system.""" diff --git a/pyomnilogic_local/sensor.py b/pyomnilogic_local/sensor.py new file mode 100644 index 0000000..9d18ee9 --- /dev/null +++ b/pyomnilogic_local/sensor.py @@ -0,0 +1,5 @@ +from pyomnilogic_local._base import OmniEquipment + + +class Sensor(OmniEquipment): + """Represents a sensor in the OmniLogic system.""" diff --git a/pyomnilogic_local/system.py b/pyomnilogic_local/system.py new file mode 100644 index 0000000..6449d9e --- /dev/null +++ b/pyomnilogic_local/system.py @@ -0,0 +1,6 @@ +from pyomnilogic_local._base import OmniEquipment + + +class System(OmniEquipment): + + pass diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 5fd8b6e..f1ba4cb 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -3,9 +3,9 @@ import pytest -from pyomnilogic_local.exceptions import OmniTimeoutException +from pyomnilogic_local.api.exceptions import OmniTimeoutException +from pyomnilogic_local.api.protocol import OmniLogicMessage, OmniLogicProtocol from pyomnilogic_local.omnitypes import ClientType, MessageType -from pyomnilogic_local.protocol import OmniLogicMessage, OmniLogicProtocol def test_parse_basic_ack() -> None: From 5d1029f79bfaa1d54768ffd2582ca307f61850a1 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:35:00 -0500 Subject: [PATCH 02/61] ci: swap poetry to uv and update some pre-commit checks --- .github/workflows/ci.yml | 26 +- .gitignore | 9 +- .pre-commit-config.yaml | 30 +- poetry.lock | 856 -------------------------- pyomnilogic_local/colorlogiclight.py | 1 - pyomnilogic_local/models/mspconfig.py | 2 +- pyomnilogic_local/models/telemetry.py | 19 +- pyomnilogic_local/system.py | 1 - pyproject.toml | 36 +- 9 files changed, 59 insertions(+), 921 deletions(-) delete mode 100644 poetry.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27a56a5..83dc3e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,20 +29,18 @@ jobs: python-version: - "3.12" - "3.13" - poetry-version: - - "2.2.1" steps: - uses: actions/checkout@v3 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Set up Poetry - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: ${{ matrix.poetry-version }} - name: Install Dependencies - run: poetry install + run: uv sync --all-extras shell: bash - uses: pre-commit/action@v3.0.0 @@ -53,8 +51,6 @@ jobs: python-version: - "3.12" - "3.13" - poetry-version: - - "2.2.1" os: - ubuntu-latest - windows-latest @@ -62,19 +58,19 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Set up Poetry - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: ${{ matrix.poetry-version }} - name: Install Dependencies - run: poetry install + run: uv sync --all-extras shell: bash - name: Test with Pytest - run: poetry run pytest + run: uv run pytest shell: bash release: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 1dbefcb..eeb1b41 100644 --- a/.gitignore +++ b/.gitignore @@ -94,12 +94,9 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +# uv +# uv.lock should be committed to version control for reproducibility +# uv.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 682692d..e80d67a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,10 +17,21 @@ repos: - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/python-poetry/poetry - rev: 2.2.1 + - repo: https://github.com/astral-sh/uv-pre-commit + # uv version. + rev: 0.9.5 hooks: - - id: poetry-check + - id: uv-lock + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.14.1 + hooks: + - id: ruff-check + args: [ --fix ] + - id: ruff-format - repo: https://github.com/PyCQA/isort rev: 7.0.0 hooks: @@ -29,22 +40,11 @@ repos: rev: 25.9.0 hooks: - id: black - - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 - hooks: - - id: codespell - exclude: poetry.lock - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.14.1 - hooks: - - id: ruff - args: - - --fix - repo: local hooks: - id: pylint name: pylint - entry: poetry run pylint + entry: uv run pylint language: system types: [python] require_serial: true diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 6f3a546..0000000 --- a/poetry.lock +++ /dev/null @@ -1,856 +0,0 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "astroid" -version = "4.0.1" -description = "An abstract syntax tree for Python with inference support." -optional = false -python-versions = ">=3.10.0" -groups = ["dev"] -files = [ - {file = "astroid-4.0.1-py3-none-any.whl", hash = "sha256:37ab2f107d14dc173412327febf6c78d39590fdafcb44868f03b6c03452e3db0"}, - {file = "astroid-4.0.1.tar.gz", hash = "sha256:0d778ec0def05b935e198412e62f9bcca8b3b5c39fdbe50b0ba074005e477aab"}, -] - -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - -[[package]] -name = "click" -version = "8.0.4" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, - {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} - -[[package]] -name = "coverage" -version = "7.11.0" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31"}, - {file = "coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841"}, - {file = "coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf"}, - {file = "coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969"}, - {file = "coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847"}, - {file = "coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e"}, - {file = "coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c"}, - {file = "coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9"}, - {file = "coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745"}, - {file = "coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1"}, - {file = "coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2"}, - {file = "coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5"}, - {file = "coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0"}, - {file = "coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad"}, - {file = "coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1"}, - {file = "coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48"}, - {file = "coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040"}, - {file = "coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05"}, - {file = "coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a"}, - {file = "coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b"}, - {file = "coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca"}, - {file = "coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2"}, - {file = "coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268"}, - {file = "coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836"}, - {file = "coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497"}, - {file = "coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4"}, - {file = "coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721"}, - {file = "coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad"}, - {file = "coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479"}, - {file = "coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f"}, - {file = "coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11"}, - {file = "coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73"}, - {file = "coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547"}, - {file = "coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3"}, - {file = "coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68"}, - {file = "coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "dill" -version = "0.4.0" -description = "serialize all of Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, - {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, -] - -[package.extras] -graph = ["objgraph (>=1.7.2)"] -profile = ["gprof2dot (>=2022.7.29)"] - -[[package]] -name = "distlib" -version = "0.4.0" -description = "Distribution utilities" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, - {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, -] - -[[package]] -name = "filelock" -version = "3.20.0" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"}, - {file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"}, -] - -[[package]] -name = "identify" -version = "2.6.15" -description = "File identification library for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"}, - {file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "isort" -version = "6.1.0" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784"}, - {file = "isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481"}, -] - -[package.extras] -colors = ["colorama"] -plugins = ["setuptools"] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mypy" -version = "1.18.2" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, - {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, - {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, - {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, - {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, - {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, - {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, - {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, - {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, - {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, - {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, - {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, - {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, - {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, - {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, - {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, - {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, - {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, - {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, - {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, - {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, - {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, - {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, - {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, - {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, - {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, - {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, - {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, - {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, - {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, - {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, - {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, - {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, - {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, - {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, - {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, - {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, - {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, -] - -[package.dependencies] -mypy_extensions = ">=1.0.0" -pathspec = ">=0.9.0" -typing_extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - -[[package]] -name = "nodeenv" -version = "1.9.1" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, -] - -[[package]] -name = "packaging" -version = "25.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "platformdirs" -version = "4.5.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, - {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, -] - -[package.extras] -docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] -type = ["mypy (>=1.18.2)"] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "pre-commit" -version = "4.3.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8"}, - {file = "pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "pydantic" -version = "2.12.3" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf"}, - {file = "pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.41.4" -typing-extensions = ">=4.14.1" -typing-inspection = ">=0.4.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.41.4" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e"}, - {file = "pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9"}, - {file = "pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57"}, - {file = "pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc"}, - {file = "pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80"}, - {file = "pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db"}, - {file = "pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887"}, - {file = "pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8"}, - {file = "pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746"}, - {file = "pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89"}, - {file = "pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1"}, - {file = "pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0"}, - {file = "pydantic_core-2.41.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:646e76293345954acea6966149683047b7b2ace793011922208c8e9da12b0062"}, - {file = "pydantic_core-2.41.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc8e85a63085a137d286e2791037f5fdfff0aabb8b899483ca9c496dd5797338"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:692c622c8f859a17c156492783902d8370ac7e121a611bd6fe92cc71acf9ee8d"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1e2906efb1031a532600679b424ef1d95d9f9fb507f813951f23320903adbd7"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04e2f7f8916ad3ddd417a7abdd295276a0bf216993d9318a5d61cc058209166"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df649916b81822543d1c8e0e1d079235f68acdc7d270c911e8425045a8cfc57e"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c529f862fdba70558061bb936fe00ddbaaa0c647fd26e4a4356ef1d6561891"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3b4c5a1fd3a311563ed866c2c9b62da06cb6398bee186484ce95c820db71cb"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6e0fc40d84448f941df9b3334c4b78fe42f36e3bf631ad54c3047a0cdddc2514"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:44e7625332683b6c1c8b980461475cde9595eff94447500e80716db89b0da005"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:170ee6835f6c71081d031ef1c3b4dc4a12b9efa6a9540f93f95b82f3c7571ae8"}, - {file = "pydantic_core-2.41.4-cp39-cp39-win32.whl", hash = "sha256:3adf61415efa6ce977041ba9745183c0e1f637ca849773afa93833e04b163feb"}, - {file = "pydantic_core-2.41.4-cp39-cp39-win_amd64.whl", hash = "sha256:a238dd3feee263eeaeb7dc44aea4ba1364682c4f9f9467e6af5596ba322c2332"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f"}, - {file = "pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5"}, -] - -[package.dependencies] -typing-extensions = ">=4.14.1" - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pylint" -version = "4.0.2" -description = "python code static checker" -optional = false -python-versions = ">=3.10.0" -groups = ["dev"] -files = [ - {file = "pylint-4.0.2-py3-none-any.whl", hash = "sha256:9627ccd129893fb8ee8e8010261cb13485daca83e61a6f854a85528ee579502d"}, - {file = "pylint-4.0.2.tar.gz", hash = "sha256:9c22dfa52781d3b79ce86ab2463940f874921a3e5707bcfc98dd0c019945014e"}, -] - -[package.dependencies] -astroid = ">=4.0.1,<=4.1.dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} -isort = ">=5,<5.13 || >5.13,<8" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2" -tomlkit = ">=0.10.1" - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] - -[[package]] -name = "pytest" -version = "8.4.2" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "1.2.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, - {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, -] - -[package.dependencies] -pytest = ">=8.2,<9" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, - {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, -] - -[package.dependencies] -coverage = {version = ">=7.10.6", extras = ["toml"]} -pluggy = ">=1.2" -pytest = ">=7" - -[package.extras] -testing = ["process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pyyaml" -version = "6.0.3" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, - {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, - {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, - {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, - {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, - {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, - {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, - {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, - {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, - {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, -] - -[[package]] -name = "scapy" -version = "2.6.1" -description = "Scapy: interactive packet manipulation tool" -optional = false -python-versions = "<4,>=3.7" -groups = ["cli"] -files = [ - {file = "scapy-2.6.1-py3-none-any.whl", hash = "sha256:88a998572049b511a1f3e44f4aa7c62dd39c6ea2aa1bb58434f503956641789d"}, - {file = "scapy-2.6.1.tar.gz", hash = "sha256:7600d7e2383c853e5c3a6e05d37e17643beebf2b3e10d7914dffcc3bc3c6e6c5"}, -] - -[package.extras] -all = ["cryptography (>=2.0)", "ipython", "matplotlib", "pyx"] -cli = ["ipython"] -doc = ["sphinx (>=7.0.0)", "sphinx-rtd-theme (>=1.3.0)", "tox (>=3.0.0)"] - -[[package]] -name = "tomlkit" -version = "0.13.3" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, - {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[[package]] -name = "virtualenv" -version = "20.35.3" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a"}, - {file = "virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] - -[[package]] -name = "xmltodict" -version = "0.13.0" -description = "Makes working with XML feel like you are working with JSON" -optional = false -python-versions = ">=3.4" -groups = ["main"] -files = [ - {file = "xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852"}, - {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"}, -] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.12,<4.0.0" -content-hash = "63006bf4923ea08a13f1aa55ef74fce1ada37b297afc0887676af5759892c54e" diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index 508dd06..6c95dfe 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -1,3 +1,2 @@ class ColorLogicLight: - pass diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 0a3ad64..32585cb 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -333,7 +333,7 @@ class MSPSchedule(OmniBase): | MSPColorLogicLight ) -type MSPConfigType = (MSPSystem | MSPEquipmentType) +type MSPConfigType = MSPSystem | MSPEquipmentType class MSPConfig(BaseModel): diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index 7f49778..f28c00f 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, SupportsInt, TypeVar, cast, overload +from typing import Any, SupportsInt, cast, overload from pydantic import BaseModel, ConfigDict, Field, ValidationError, computed_field from xmltodict import parse as xml_parse @@ -88,7 +88,8 @@ class TelemetryChlorinator(BaseModel): operating_mode: ChlorinatorOperatingMode | int = Field(alias="@operatingMode") enable: bool = Field(alias="@enable") - @computed_field + @computed_field # type: ignore[prop-decorator] + @property def status(self) -> list[str]: """Decode status bitmask into a list of active status flag names. @@ -101,7 +102,8 @@ def status(self) -> list[str]: """ return [flag.name for flag in ChlorinatorStatus if self.status_raw & flag.value and flag.name is not None] - @computed_field + @computed_field # type: ignore[prop-decorator] + @property def alerts(self) -> list[str]: """Decode chlrAlert bitmask into a list of active alert flag names. @@ -131,7 +133,8 @@ def alerts(self) -> list[str]: return final_flags - @computed_field + @computed_field # type: ignore[prop-decorator] + @property def errors(self) -> list[str]: """Decode chlrError bitmask into a list of active error flag names. @@ -161,7 +164,8 @@ def errors(self) -> list[str]: return final_flags - @computed_field + @computed_field # type: ignore[prop-decorator] + @property def active(self) -> bool: """Check if the chlorinator is actively generating chlorine. @@ -326,15 +330,10 @@ class Telemetry(BaseModel): @staticmethod def load_xml(xml: str) -> Telemetry: - TypeVar("KT") - TypeVar("VT", SupportsInt, Any) - @overload def xml_postprocessor(path: Any, key: Any, value: SupportsInt) -> tuple[Any, SupportsInt]: ... - @overload def xml_postprocessor(path: Any, key: Any, value: Any) -> tuple[Any, Any]: ... - def xml_postprocessor(path: Any, key: Any, value: SupportsInt | Any) -> tuple[Any, SupportsInt | Any]: """Post process XML to attempt to convert values to int. diff --git a/pyomnilogic_local/system.py b/pyomnilogic_local/system.py index 6449d9e..162c98f 100644 --- a/pyomnilogic_local/system.py +++ b/pyomnilogic_local/system.py @@ -2,5 +2,4 @@ class System(OmniEquipment): - pass diff --git a/pyproject.toml b/pyproject.toml index 7372820..9a1c82e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,23 +19,17 @@ dependencies = [ [project.scripts] omnilogic = "pyomnilogic_local.cli.cli:entrypoint" -[tool.poetry] -packages = [{include = "pyomnilogic_local"}] - -[tool.poetry.group.dev.dependencies] -pre-commit = "^4.0.0" -mypy = "^1.18.2" -pylint = "^4.0.0" -pytest = "^8.0.0" -pytest-cov = "^7.0.0" -pytest-asyncio = "^1.2.0" - -[tool.poetry.group.cli.dependencies] -scapy = "^2.6.1" +[project.optional-dependencies] +cli = [ + "scapy>=2.6.1,<3.0.0", +] [build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pyomnilogic_local"] [tool.black] line-length=140 @@ -140,4 +134,14 @@ line-length = 140 [tool.semantic_release] branch = "main" version_toml = "pyproject.toml:project.version" -build_command = "pip install poetry && poetry build" +build_command = "pip install hatch && hatch build" + +[dependency-groups] +dev = [ + "pre-commit>=4.0.0,<5.0.0", + "mypy>=1.18.2,<2.0.0", + "pylint>=4.0.0,<5.0.0", + "pytest>=8.0.0,<9.0.0", + "pytest-cov>=7.0.0,<8.0.0", + "pytest-asyncio>=1.2.0,<2.0.0", +] From ac80788253a18fec7970446efa11a7c7c9b95529 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:38:08 -0500 Subject: [PATCH 03/61] fix: add missing uv.lock file --- .pre-commit-config.yaml | 1 + pyomnilogic_local/omnilogic.py | 5 - uv.lock | 594 +++++++++++++++++++++++++++++++++ 3 files changed, 595 insertions(+), 5 deletions(-) create mode 100644 uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e80d67a..69515d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,7 @@ repos: rev: v2.4.1 hooks: - id: codespell + exclude: uv.lock - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.14.1 hooks: diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index 679298c..58b2923 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -61,8 +61,3 @@ def _update_equipment(self) -> None: self.system = System(equipment_mspconfig) if equipment_mspconfig.omni_type == OmniType.BACKYARD: self.backyard = Backyard(equipment_mspconfig, self.telemetry) - - # if hasattr(self, "mspconfig") and self.mspconfig is not None: - # _LOGGER.debug(self.mspconfig) - # if hasattr(self, "telemetry") and self.telemetry is not None: - # _LOGGER.debug(self.telemetry) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9d8e905 --- /dev/null +++ b/uv.lock @@ -0,0 +1,594 @@ +version = 1 +revision = 3 +requires-python = ">=3.12, <4.0.0" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "astroid" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/d1/6eee8726a863f28ff50d26c5eacb1a590f96ccbb273ce0a8c047ffb10f5a/astroid-4.0.1.tar.gz", hash = "sha256:0d778ec0def05b935e198412e62f9bcca8b3b5c39fdbe50b0ba074005e477aab", size = 405414, upload-time = "2025-10-11T15:15:42.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/f4/034361a9cbd9284ef40c8ad107955ede4efae29cbc17a059f63f6569c06a/astroid-4.0.1-py3-none-any.whl", hash = "sha256:37ab2f107d14dc173412327febf6c78d39590fdafcb44868f03b6c03452e3db0", size = 276268, upload-time = "2025-10-11T15:15:40.585Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/db/86f6906a7c7edc1a52b2c6682d6dd9be775d73c0dfe2b84f8923dfea5784/coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1", size = 216098, upload-time = "2025-10-15T15:13:02.916Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/e7b26157048c7ba555596aad8569ff903d6cd67867d41b75287323678ede/coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007", size = 216331, upload-time = "2025-10-15T15:13:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/1ce6bf444f858b83a733171306134a0544eaddf1ca8851ede6540a55b2ad/coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46", size = 247825, upload-time = "2025-10-15T15:13:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893", size = 250573, upload-time = "2025-10-15T15:13:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/b0ff3641a320abb047258d36ed1c21d16be33beed4152628331a1baf3365/coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115", size = 251706, upload-time = "2025-10-15T15:13:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/59/c8/5a586fe8c7b0458053d9c687f5cff515a74b66c85931f7fe17a1c958b4ac/coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415", size = 248221, upload-time = "2025-10-15T15:13:10.964Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ff/3a25e3132804ba44cfa9a778cdf2b73dbbe63ef4b0945e39602fc896ba52/coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186", size = 249624, upload-time = "2025-10-15T15:13:12.5Z" }, + { url = "https://files.pythonhosted.org/packages/c5/12/ff10c8ce3895e1b17a73485ea79ebc1896a9e466a9d0f4aef63e0d17b718/coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d", size = 247744, upload-time = "2025-10-15T15:13:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/d500b91f5471b2975947e0629b8980e5e90786fe316b6d7299852c1d793d/coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d", size = 247325, upload-time = "2025-10-15T15:13:16.438Z" }, + { url = "https://files.pythonhosted.org/packages/77/11/dee0284fbbd9cd64cfce806b827452c6df3f100d9e66188e82dfe771d4af/coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2", size = 249180, upload-time = "2025-10-15T15:13:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/cdf1def928f0a150a057cab03286774e73e29c2395f0d30ce3d9e9f8e697/coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5", size = 218479, upload-time = "2025-10-15T15:13:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/ff/55/e5884d55e031da9c15b94b90a23beccc9d6beee65e9835cd6da0a79e4f3a/coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0", size = 219290, upload-time = "2025-10-15T15:13:21.593Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/faa930cfc71c1d16bc78f9a19bb73700464f9c331d9e547bfbc1dbd3a108/coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad", size = 217924, upload-time = "2025-10-15T15:13:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" }, + { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" }, + { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" }, + { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, +] + +[[package]] +name = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, + { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" }, + { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pylint" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/f8/2feda2bc72654811f2596e856c33c2a98225fba717df2b55c8d6a1f5cdad/pylint-4.0.2.tar.gz", hash = "sha256:9c22dfa52781d3b79ce86ab2463940f874921a3e5707bcfc98dd0c019945014e", size = 1572401, upload-time = "2025-10-20T13:02:34.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/8b/2e814a255436fc6d604a60f1e8b8a186e05082aa3c0cabfd9330192496a2/pylint-4.0.2-py3-none-any.whl", hash = "sha256:9627ccd129893fb8ee8e8010261cb13485daca83e61a6f854a85528ee579502d", size = 536019, upload-time = "2025-10-20T13:02:32.778Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-omnilogic-local" +version = "0.19.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "xmltodict" }, +] + +[package.optional-dependencies] +cli = [ + { name = "scapy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0.0,<8.4.0" }, + { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, + { name = "scapy", marker = "extra == 'cli'", specifier = ">=2.6.1,<3.0.0" }, + { name = "xmltodict", specifier = ">=0.13.0,<2.0.0" }, +] +provides-extras = ["cli"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.18.2,<2.0.0" }, + { name = "pre-commit", specifier = ">=4.0.0,<5.0.0" }, + { name = "pylint", specifier = ">=4.0.0,<5.0.0" }, + { name = "pytest", specifier = ">=8.0.0,<9.0.0" }, + { name = "pytest-asyncio", specifier = ">=1.2.0,<2.0.0" }, + { name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "scapy" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/2f/035d3888f26d999e9680af8c7ddb7ce4ea0fd8d0e01c000de634c22dcf13/scapy-2.6.1.tar.gz", hash = "sha256:7600d7e2383c853e5c3a6e05d37e17643beebf2b3e10d7914dffcc3bc3c6e6c5", size = 2247754, upload-time = "2024-11-05T08:43:23.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/34/8695b43af99d0c796e4b7933a0d7df8925f43a8abdd0ff0f6297beb4de3a/scapy-2.6.1-py3-none-any.whl", hash = "sha256:88a998572049b511a1f3e44f4aa7c62dd39c6ea2aa1bb58434f503956641789d", size = 2420670, upload-time = "2024-11-05T08:43:21.285Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/d5/b0ccd381d55c8f45d46f77df6ae59fbc23d19e901e2d523395598e5f4c93/virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44", size = 6002907, upload-time = "2025-10-10T21:23:33.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a", size = 5981061, upload-time = "2025-10-10T21:23:30.433Z" }, +] + +[[package]] +name = "xmltodict" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/aa/917ceeed4dbb80d2f04dbd0c784b7ee7bba8ae5a54837ef0e5e062cd3cfb/xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649", size = 25725, upload-time = "2025-09-17T21:59:26.459Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/20/69a0e6058bc5ea74892d089d64dfc3a62ba78917ec5e2cfa70f7c92ba3a5/xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d", size = 13893, upload-time = "2025-09-17T21:59:24.859Z" }, +] From e050e3ed2350106775f19b33501fc2f05d7c4ce1 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:49:02 -0500 Subject: [PATCH 04/61] feat: additional work on new interaction layer --- pyomnilogic_local/_base.py | 149 ++++++++++++++++++-------- pyomnilogic_local/api/api.py | 7 +- pyomnilogic_local/backyard.py | 17 +-- pyomnilogic_local/bow.py | 107 +++++++++++++++++- pyomnilogic_local/chlorinator.py | 7 ++ pyomnilogic_local/cli/get/backyard.py | 2 +- pyomnilogic_local/cli/get/lights.py | 2 +- pyomnilogic_local/colorlogiclight.py | 115 +++++++++++++++++++- pyomnilogic_local/csad.py | 7 ++ pyomnilogic_local/filter.py | 7 ++ pyomnilogic_local/heater.py | 7 ++ pyomnilogic_local/models/mspconfig.py | 40 ++++--- pyomnilogic_local/models/telemetry.py | 17 ++- pyomnilogic_local/omnilogic.py | 21 ++-- pyomnilogic_local/omnitypes.py | 36 ++++++- pyomnilogic_local/pump.py | 7 ++ pyomnilogic_local/relay.py | 4 +- pyomnilogic_local/sensor.py | 9 +- pyomnilogic_local/system.py | 25 ++++- pyomnilogic_local/util.py | 4 + pyproject.toml | 2 +- uv.lock | 2 +- 22 files changed, 488 insertions(+), 106 deletions(-) create mode 100644 pyomnilogic_local/chlorinator.py create mode 100644 pyomnilogic_local/csad.py create mode 100644 pyomnilogic_local/filter.py create mode 100644 pyomnilogic_local/heater.py create mode 100644 pyomnilogic_local/pump.py diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py index b8de730..7030e1c 100644 --- a/pyomnilogic_local/_base.py +++ b/pyomnilogic_local/_base.py @@ -1,61 +1,116 @@ -from typing import Any +import logging +from typing import Generic, TypeVar, cast +from pyomnilogic_local.api.api import OmniLogicAPI from pyomnilogic_local.models import MSPEquipmentType, Telemetry +from pyomnilogic_local.models.telemetry import TelemetryType +# Define type variables for generic equipment types +MSPConfigT = TypeVar("MSPConfigT", bound=MSPEquipmentType) +TelemetryT = TypeVar("TelemetryT", bound=TelemetryType | None) -class OmniEquipment: - """Base class for OmniLogic equipment.""" - def __init__(self, mspconfig: MSPEquipmentType, telemetry: Telemetry | None = None) -> None: - """Initialize the equipment with configuration and telemetry data.""" - # If the Equipment has subdevices, we don't store those as part of this device's config - # They will get parsed and stored as their own equipment instances +_LOGGER = logging.getLogger(__name__) + + +class OmniEquipment(Generic[MSPConfigT, TelemetryT]): + """Base class for OmniLogic equipment. + + Generic parameters: + MSPConfigT: The specific MSP configuration type (e.g., MSPBoW, MSPRelay) + TelemetryT: The specific telemetry type (e.g., TelemetryBoW, TelemetryRelay, or None for equipment without telemetry) + """ + + mspconfig: MSPConfigT + telemetry: TelemetryT + + # Use a forward reference for the type hint to avoid issues with self-referential generics + child_equipment: dict[int, "OmniEquipment[MSPConfigT, TelemetryT]"] + + def __init__(self, _api: OmniLogicAPI, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: + """Initialize the equipment with configuration and telemetry data. + + Args: + _api: The OmniLogic API instance + mspconfig: The MSP configuration for this specific equipment + telemetry: The full Telemetry object containing all equipment telemetry + """ + self._api = _api + + self.update_config(mspconfig) + + if telemetry is not None: + self.update_telemetry(telemetry) + + @property + def bow_id(self) -> int | None: + """The bow ID of the equipment.""" + return self.mspconfig.bow_id + + @property + def name(self) -> str | None: + """The name of the equipment.""" + return self.mspconfig.name + + @property + def system_id(self) -> int | None: + """The system ID of the equipment.""" + return self.mspconfig.system_id + + @property + def omni_type(self) -> str | None: + """The OmniType of the equipment.""" + return self.mspconfig.omni_type + + def update(self, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + self.update_config(mspconfig) + if telemetry is not None: + self.update_telemetry(telemetry) + + self._update_equipment(mspconfig, telemetry) + + def _update_equipment(self, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: + """Hook to allow classes to trigger updates of sub-equipment.""" + + def update_config(self, mspconfig: MSPConfigT) -> None: + """Update the configuration data for the equipment.""" try: - self.mspconfig = mspconfig.without_subdevices() + # If the Equipment has subdevices, we don't store those as part of this device's config + # They will get parsed and stored as their own equipment instances + self.mspconfig = cast(MSPConfigT, mspconfig.without_subdevices()) except AttributeError: self.mspconfig = mspconfig - if hasattr(self, "telemetry") and telemetry is not None: - self.telemetry = telemetry.get_telem_by_systemid(self.mspconfig.system_id) - - # Populate fields from MSP configuration and telemetry - # This is some moderate magic to avoid having to manually set each field - # The TL;DR is that we loop over all fields defined in the MSPConfig and Telemetry models - # and set the corresponding attributes on this equipment instance. - for field in self.mspconfig.__class__.model_fields: - if getattr(self.mspconfig, field, None) is not None: - setattr(self, field, self._from_mspconfig(field)) - for field in self.mspconfig.__class__.model_computed_fields: - if getattr(self.mspconfig, field, None) is not None: - setattr(self, field, self._from_mspconfig(field)) - if hasattr(self, "telemetry") and self.telemetry is not None: - for field in self.telemetry.__class__.model_fields: - if getattr(self.telemetry, field, None) is not None: - setattr(self, field, self._from_telemetry(field)) - for field in self.telemetry.__class__.model_computed_fields: - if getattr(self.telemetry, field, None) is not None: - setattr(self, field, self._from_telemetry(field)) - - def update_config(self, mspconfig: MSPEquipmentType) -> None: - """Update the configuration data for the equipment.""" - if hasattr(self, "mspconfig"): - self.mspconfig = mspconfig.without_subdevices() - else: - raise NotImplementedError("This equipment does not have MSP configuration.") - def update_telemetry(self, telemetry: Telemetry) -> None: """Update the telemetry data for the equipment.""" - if hasattr(self, "telemetry"): - self.telemetry = telemetry.get_telem_by_systemid(self.mspconfig.system_id) + # Only update telemetry if this equipment type has telemetry + # if hasattr(self, "telemetry"): + # Extract the specific telemetry for this equipment from the full telemetry object + # Note: Some equipment (like sensors) don't have their own telemetry, so this may be None + if specific_telemetry := telemetry.get_telem_by_systemid(self.mspconfig.system_id) is not None: + self.telemetry = cast(TelemetryT, specific_telemetry) else: - raise NotImplementedError("This equipment does not have telemetry data.") + self.telemetry = cast(TelemetryT, None) + # else: + # raise NotImplementedError("This equipment does not have telemetry data.") - def _from_mspconfig(self, attribute: str) -> Any: - """Helper method to get a value from the MSP configuration.""" - return getattr(self.mspconfig, attribute, None) + # def _update_equipment(self, telemetry: Telemetry) -> None: + # pass - def _from_telemetry(self, attribute: str) -> Any: - """Helper method to get a value from the telemetry data.""" - if hasattr(self, "telemetry"): - return getattr(self.telemetry, attribute, None) - return None + # def _update_equipment(self, telemetry: Telemetry) -> None: + # """Update any child equipment based on the latest MSPConfig and Telemetry data.""" + # for _, equipment_mspconfig in self.mspconfig: + # system_id = equipment_mspconfig.system_id + # if system_id is None: + # _LOGGER.debug("Skipping equipment update: system_id is None: %s", equipment_mspconfig) + # continue + # if system_id in self.child_equipment: + # # Update existing child equipment + # child_equipment = self.child_equipment[system_id] + # if child_equipment is not None: + # child_equipment.update_config(equipment_mspconfig) + # if hasattr(self, "telemetry"): + # child_equipment.update_telemetry(telemetry) + # else: + # equipment = create_equipment(self, equipment_mspconfig, telemetry) diff --git a/pyomnilogic_local/api/api.py b/pyomnilogic_local/api/api.py index fed0c08..d754e1c 100644 --- a/pyomnilogic_local/api/api.py +++ b/pyomnilogic_local/api/api.py @@ -12,12 +12,9 @@ from ..omnitypes import ( ColorLogicBrightness, - ColorLogicShow25, - ColorLogicShow40, - ColorLogicShowUCL, - ColorLogicShowUCLV2, ColorLogicSpeed, HeaterMode, + LightShows, MessageType, ) from .protocol import OmniLogicProtocol @@ -380,7 +377,7 @@ async def async_set_light_show( self, pool_id: int, equipment_id: int, - show: ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2, + show: LightShows, speed: ColorLogicSpeed = ColorLogicSpeed.ONE_TIMES, brightness: ColorLogicBrightness = ColorLogicBrightness.ONE_HUNDRED_PERCENT, reserved: int = 0, diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py index cba0c86..4b6cb75 100644 --- a/pyomnilogic_local/backyard.py +++ b/pyomnilogic_local/backyard.py @@ -1,5 +1,6 @@ +from pyomnilogic_local.api.api import OmniLogicAPI from pyomnilogic_local.models.mspconfig import MSPBackyard -from pyomnilogic_local.models.telemetry import Telemetry +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryBackyard from ._base import OmniEquipment from .bow import Bow @@ -8,7 +9,7 @@ from .sensor import Sensor -class Backyard(OmniEquipment): +class Backyard(OmniEquipment[MSPBackyard, TelemetryBackyard]): """Represents the backyard equipment in the OmniLogic system.""" bow: list[Bow] = [] @@ -16,8 +17,8 @@ class Backyard(OmniEquipment): relays: list[Relay] = [] sensors: list[Sensor] = [] - def __init__(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: - super().__init__(mspconfig, telemetry) + def __init__(self, _api: OmniLogicAPI, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + super().__init__(_api, mspconfig, telemetry) self._update_bows(mspconfig, telemetry) self._update_relays(mspconfig, telemetry) @@ -29,7 +30,7 @@ def _update_bows(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: self.bow = [] return - self.bow = [Bow(bow, telemetry) for bow in mspconfig.bow] + self.bow = [Bow(self._api, bow, telemetry) for bow in mspconfig.bow] def _update_relays(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: """Update the relays based on the MSP configuration.""" @@ -37,12 +38,12 @@ def _update_relays(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: self.relays = [] return - self.relays = [Relay(relay, telemetry) for relay in mspconfig.relay] + self.relays = [Relay(self._api, relay, telemetry) for relay in mspconfig.relay] def _update_sensors(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: - """Update the sensors, bows, lights, and relays based on the MSP configuration.""" + """Update the sensors based on the MSP configuration.""" if mspconfig.sensor is None: self.sensors = [] return - self.sensors = [Sensor(sensor, telemetry) for sensor in mspconfig.sensor] + self.sensors = [Sensor(self._api, sensor, telemetry) for sensor in mspconfig.sensor] diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index b48b238..93ef5fb 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -1,5 +1,110 @@ from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.api.api import OmniLogicAPI +from pyomnilogic_local.chlorinator import Chlorinator +from pyomnilogic_local.colorlogiclight import _LOGGER, ColorLogicLight +from pyomnilogic_local.csad import CSAD +from pyomnilogic_local.filter import Filter +from pyomnilogic_local.heater import Heater +from pyomnilogic_local.models.mspconfig import MSPBoW +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryBoW +from pyomnilogic_local.pump import Pump +from pyomnilogic_local.relay import Relay +from pyomnilogic_local.sensor import Sensor -class Bow(OmniEquipment): +class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): """Represents a bow in the OmniLogic system.""" + + filters: list[Filter] = [] + heater: Heater | None = None + relays: list[Relay] = [] + sensors: list[Sensor] = [] + lights: list[ColorLogicLight] = [] + pumps: list[Pump] = [] + chlorinator: Chlorinator | None = None + csads: list[CSAD] = [] + + def __init__(self, _api: OmniLogicAPI, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + super().__init__(_api, mspconfig, telemetry) + + @property + def equip_type(self) -> str: + """The equipment type of the bow.""" + return self.mspconfig.equip_type + + def _update_equipment(self, mspconfig: MSPBoW, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + if telemetry is None: + _LOGGER.warning("No telemetry provided to update Bow equipment.") + return + self._update_filters(self.mspconfig, telemetry) + self._update_heater(self.mspconfig, telemetry) + self._update_sensors(self.mspconfig, telemetry) + self._update_lights(self.mspconfig, telemetry) + self._update_pumps(self.mspconfig, telemetry) + self._update_chlorinators(self.mspconfig, telemetry) + self._update_csads(self.mspconfig, telemetry) + + def _update_filters(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the filters based on the MSP configuration.""" + if mspconfig.filter is None: + self.filters = [] + return + + self.filters = [Filter(self._api, filter_, telemetry) for filter_ in mspconfig.filter] + + def _update_heater(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the heater based on the MSP configuration.""" + if mspconfig.heater is None: + self.heater = None + return + + self.heater = Heater(self._api, mspconfig.heater, telemetry) + + def _update_relays(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the relays based on the MSP configuration.""" + if mspconfig.relay is None: + self.relays = [] + return + + self.relays = [Relay(self._api, relay, telemetry) for relay in mspconfig.relay] + + def _update_sensors(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the sensors based on the MSP configuration.""" + if mspconfig.sensor is None: + self.sensors = [] + return + + self.sensors = [Sensor(self._api, sensor, telemetry) for sensor in mspconfig.sensor] + + def _update_lights(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the lights based on the MSP configuration.""" + if mspconfig.colorlogic_light is None: + self.lights = [] + return + + self.lights = [ColorLogicLight(self._api, 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.""" + if mspconfig.pump is None: + self.pumps = [] + return + + self.pumps = [Pump(self._api, pump, telemetry) for pump in mspconfig.pump] + + def _update_chlorinators(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the chlorinators based on the MSP configuration.""" + if mspconfig.chlorinator is None: + self.chlorinator = None + return + + self.chlorinator = Chlorinator(self._api, mspconfig.chlorinator, telemetry) + + def _update_csads(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the CSADs based on the MSP configuration.""" + if mspconfig.csad is None: + self.csads = [] + return + + self.csads = [CSAD(self._api, csad, telemetry) for csad in mspconfig.csad] diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py new file mode 100644 index 0000000..0218187 --- /dev/null +++ b/pyomnilogic_local/chlorinator.py @@ -0,0 +1,7 @@ +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPChlorinator +from pyomnilogic_local.models.telemetry import TelemetryChlorinator + + +class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): + """Represents a chlorinator in the OmniLogic system.""" diff --git a/pyomnilogic_local/cli/get/backyard.py b/pyomnilogic_local/cli/get/backyard.py index 44e8e99..ec95af0 100644 --- a/pyomnilogic_local/cli/get/backyard.py +++ b/pyomnilogic_local/cli/get/backyard.py @@ -76,7 +76,7 @@ def _print_backyard_info(backyardconfig: MSPBackyard, telemetry: TelemetryType | if backyardconfig.bow: equipment_counts.append(f"Bodies of Water: {len(backyardconfig.bow)}") for bow in backyardconfig.bow: - equipment_counts.append(f" - {bow.name} ({bow.type})") + equipment_counts.append(f" - {bow.name} ({bow.equip_type})") if backyardconfig.sensor: equipment_counts.append(f"Backyard Sensors: {len(backyardconfig.sensor)}") diff --git a/pyomnilogic_local/cli/get/lights.py b/pyomnilogic_local/cli/get/lights.py index 7dfd20c..e6bd69a 100644 --- a/pyomnilogic_local/cli/get/lights.py +++ b/pyomnilogic_local/cli/get/lights.py @@ -74,7 +74,7 @@ def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryColorLogicL show_names = [show.pretty() if hasattr(show, "pretty") else str(show) for show in value] value = ", ".join(show_names) if show_names else "None" elif attr_name == "show" and value is not None: - value = telemetry.show_name(light.type, light.v2_active, True) if telemetry else str(value) + value = telemetry.show_name(light.equip_type, light.v2_active, True) if telemetry else str(value) elif attr_name == "speed": value = ColorLogicSpeed(value).pretty() elif attr_name == "state": diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index 6c95dfe..fd4a4b4 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -1,2 +1,113 @@ -class ColorLogicLight: - pass +import logging + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.api.api import OmniLogicAPI +from pyomnilogic_local.models.mspconfig import MSPColorLogicLight +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryColorLogicLight +from pyomnilogic_local.omnitypes import ( + ColorLogicBrightness, + ColorLogicLightType, + ColorLogicPowerState, + ColorLogicSpeed, + LightShows, +) + +_LOGGER = logging.getLogger(__name__) + + +class ColorLogicLight(OmniEquipment[MSPColorLogicLight, TelemetryColorLogicLight]): + """Represents a color logic light.""" + + def __init__(self, _api: OmniLogicAPI, mspconfig: MSPColorLogicLight, telemetry: Telemetry) -> None: + super().__init__(_api, mspconfig, telemetry) + + @property + def model(self) -> ColorLogicLightType: + """Returns the model of the light.""" + return self.mspconfig.equip_type + + @property + def v2_active(self) -> bool: + """Returns whether the light is v2 active.""" + return self.mspconfig.v2_active + + @property + def effects(self) -> list[LightShows] | None: + """Returns the effects of the light.""" + return self.mspconfig.effects + + @property + def state(self) -> ColorLogicPowerState: + """Returns the state of the light.""" + return self.telemetry.state + + @property + def show(self) -> LightShows: + """Returns the current light show.""" + return self.telemetry.show + + @property + def speed(self) -> ColorLogicSpeed: + """Returns the current speed.""" + if self.model in [ColorLogicLightType.SAM, ColorLogicLightType.TWO_FIVE, ColorLogicLightType.FOUR_ZERO, ColorLogicLightType.UCL]: + return self.telemetry.speed + # Non color-logic lights only support 1x speed + return ColorLogicSpeed.ONE_TIMES + + @property + def brightness(self) -> ColorLogicBrightness: + """Returns the current brightness.""" + if self.model in [ColorLogicLightType.SAM, ColorLogicLightType.TWO_FIVE, ColorLogicLightType.FOUR_ZERO, ColorLogicLightType.UCL]: + return self.telemetry.brightness + # Non color-logic lights only support 100% brightness + return ColorLogicBrightness.ONE_HUNDRED_PERCENT + + @property + def special_effect(self) -> int: + """Returns the current special effect.""" + return self.telemetry.special_effect + + async def turn_on(self) -> None: + """Turns the light on.""" + if self.bow_id is None or self.system_id is None: + raise ValueError("Cannot turn on light: bow_id or system_id is None") + await self._api.async_set_equipment(self.bow_id, self.system_id, True) + + async def turn_off(self) -> None: + """Turns the light off.""" + if self.bow_id is None or self.system_id is None: + raise ValueError("Cannot turn off light: bow_id or system_id is None") + await self._api.async_set_equipment(self.bow_id, self.system_id, False) + + async def set_show( + self, + show: LightShows | None = None, + speed: ColorLogicSpeed | None = None, + brightness: ColorLogicBrightness | None = None, + ) -> None: + """Sets the light show, speed, and brightness.""" + + # Non color-logic lights do not support speed or brightness control + if self.model not in [ + ColorLogicLightType.SAM, + ColorLogicLightType.TWO_FIVE, + ColorLogicLightType.FOUR_ZERO, + ColorLogicLightType.UCL, + ]: + if speed is not None: + _LOGGER.warning("Non colorlogic lights do not support speed control %s", self.model.name) + speed = ColorLogicSpeed.ONE_TIMES + if brightness is not None: + _LOGGER.warning("Non colorlogic lights do not support brightness control %s", self.model.name) + brightness = ColorLogicBrightness.ONE_HUNDRED_PERCENT + + if self.bow_id is None or self.system_id is None: + raise ValueError("Cannot set light show: bow_id or system_id is None") + + await self._api.async_set_light_show( + self.bow_id, + self.system_id, + show or self.show, # use current value if None + speed or self.speed, # use current value if None + brightness or self.brightness, # use current value if None + ) diff --git a/pyomnilogic_local/csad.py b/pyomnilogic_local/csad.py new file mode 100644 index 0000000..e827618 --- /dev/null +++ b/pyomnilogic_local/csad.py @@ -0,0 +1,7 @@ +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPCSAD +from pyomnilogic_local.models.telemetry import TelemetryCSAD + + +class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]): + """Represents a CSAD in the OmniLogic system.""" diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py new file mode 100644 index 0000000..dc4053a --- /dev/null +++ b/pyomnilogic_local/filter.py @@ -0,0 +1,7 @@ +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPFilter +from pyomnilogic_local.models.telemetry import TelemetryFilter + + +class Filter(OmniEquipment[MSPFilter, TelemetryFilter]): + """Represents a filter in the OmniLogic system.""" diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py new file mode 100644 index 0000000..64fc2f7 --- /dev/null +++ b/pyomnilogic_local/heater.py @@ -0,0 +1,7 @@ +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPVirtualHeater +from pyomnilogic_local.models.telemetry import TelemetryHeater + + +class Heater(OmniEquipment[MSPVirtualHeater, TelemetryHeater]): + """Represents a heater in the OmniLogic system.""" diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 32585cb..00e8532 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -23,7 +23,6 @@ ChlorinatorCellType, ChlorinatorDispenserType, ColorLogicLightType, - ColorLogicShow, ColorLogicShow25, ColorLogicShow40, ColorLogicShowUCL, @@ -31,13 +30,16 @@ CSADType, FilterType, HeaterType, + LightShows, OmniType, + PentairShow, PumpFunction, PumpType, RelayFunction, RelayType, SensorType, SensorUnits, + ZodiacShow, ) from .exceptions import OmniParsingException @@ -50,16 +52,16 @@ class OmniBase(BaseModel): _sub_devices: set[str] | None = None system_id: int = Field(alias="System-Id") name: str | None = Field(alias="Name", default=None) - bow_id: int | None = None + bow_id: int = -1 + omni_type: OmniType def without_subdevices(self) -> Self: data = self.model_dump(exclude=self._sub_devices, round_trip=True, by_alias=True) copied = self.model_validate(data) _LOGGER.debug("without_subdevices: original=%s, copied=%s", self, copied) return copied - # return self.copy(exclude=self._sub_devices) - def propagate_bow_id(self, bow_id: int | None) -> None: + def propagate_bow_id(self, bow_id: int) -> None: # First we set our own bow_id self.bow_id = bow_id # If we have no devices under us, we have nothing to do @@ -102,6 +104,7 @@ def convert_yes_no_to_bool(cls, data: Any) -> Any: class MSPSystem(BaseModel): model_config = ConfigDict(from_attributes=True) + # system_id: int = -1 # The System has no system-id, set it to -1 to signify this omni_type: OmniType = OmniType.SYSTEM @@ -112,7 +115,7 @@ class MSPSystem(BaseModel): class MSPSensor(OmniBase): omni_type: OmniType = OmniType.SENSOR - type: SensorType | str = Field(alias="Type") + equip_type: SensorType | str = Field(alias="Type") units: SensorUnits | str = Field(alias="Units") @@ -121,7 +124,7 @@ class MSPFilter(OmniBase): omni_type: OmniType = OmniType.FILTER - type: FilterType | str = Field(alias="Filter-Type") + equip_type: FilterType | str = Field(alias="Filter-Type") max_percent: int = Field(alias="Max-Pump-Speed") min_percent: int = Field(alias="Min-Pump-Speed") max_rpm: int = Field(alias="Max-Pump-RPM") @@ -138,7 +141,7 @@ class MSPPump(OmniBase): omni_type: OmniType = OmniType.PUMP - type: PumpType | str = Field(alias="Type") + equip_type: PumpType | str = Field(alias="Type") function: PumpFunction | str = Field(alias="Function") max_percent: int = Field(alias="Max-Pump-Speed") min_percent: int = Field(alias="Min-Pump-Speed") @@ -163,7 +166,7 @@ class MSPHeaterEquip(OmniBase): omni_type: OmniType = OmniType.HEATER_EQUIP - type: Literal["PET_HEATER"] = Field(alias="Type") + equip_type: Literal["PET_HEATER"] = Field(alias="Type") heater_type: HeaterType | str = Field(alias="Heater-Type") enabled: bool = Field(alias="Enabled") min_filter_speed: int = Field(alias="Min-Speed-For-Operation") @@ -233,7 +236,7 @@ class MSPCSAD(OmniBase): omni_type: OmniType = OmniType.CSAD enabled: bool = Field(alias="Enabled") - type: CSADType | str = Field(alias="Type") + equip_type: CSADType | str = Field(alias="Type") target_value: float = Field(alias="TargetValue") calibration_value: float = Field(alias="CalibrationValue") ph_low_alarm_value: float = Field(alias="PHLowAlarmLevel") @@ -251,15 +254,15 @@ class MSPColorLogicLight(OmniBase): omni_type: OmniType = OmniType.CL_LIGHT - type: ColorLogicLightType = Field(alias="Type") + equip_type: ColorLogicLightType = Field(alias="Type") v2_active: bool = Field(alias="V2-Active", default=False) - effects: list[ColorLogicShow] | None = None + effects: list[LightShows] | None = None def __init__(self, **data: Any) -> None: super().__init__(**data) # Get the available light shows depending on the light type. - match self.type: + match self.equip_type: case ColorLogicLightType.TWO_FIVE: self.effects = list(ColorLogicShow25) case ColorLogicLightType.FOUR_ZERO: @@ -269,8 +272,10 @@ def __init__(self, **data: Any) -> None: self.effects = list(ColorLogicShowUCLV2) else: self.effects = list(ColorLogicShowUCL) - - # self.effects = list(ColorLogicShow) if self.v2_active == "yes" else [show for show in ColorLogicShow if show.value <= 16] + case ColorLogicLightType.PENTAIR_COLOR: + self.effects = list(PentairShow) + case ColorLogicLightType.ZODIAC_COLOR: + self.effects = list(ZodiacShow) class MSPBoW(OmniBase): @@ -279,7 +284,7 @@ class MSPBoW(OmniBase): omni_type: OmniType = OmniType.BOW - type: BodyOfWaterType | str = Field(alias="Type") + equip_type: BodyOfWaterType | str = Field(alias="Type") supports_spillover: bool = Field(alias="Supports-Spillover", default=False) filter: list[MSPFilter] | None = Field(alias="Filter", default=None) relay: list[MSPRelay] | None = Field(alias="Relay", default=None) @@ -293,12 +298,15 @@ class MSPBoW(OmniBase): # We override the __init__ here so that we can trigger the propagation of the bow_id down to all of it's sub devices after the bow # itself is initialized def __init__(self, **data: Any) -> None: + # As we are requiring a bow_id on everything in OmniBase, we need to propagate it down now + # before calling super().__init__() so that it will be present for validation. super().__init__(**data) self.propagate_bow_id(self.system_id) class MSPBackyard(OmniBase): _sub_devices = {"sensor", "bow", "colorlogic_light", "relay"} + bow_id: int = -1 omni_type: OmniType = OmniType.BACKYARD @@ -312,7 +320,7 @@ class MSPSchedule(OmniBase): omni_type: OmniType = OmniType.SCHEDULE system_id: int = Field(alias="schedule-system-id") - bow_id: int | None = Field(alias="bow-system-id", default=None) + bow_id: int = Field(alias="bow-system-id") # pyright: ignore[reportGeneralTypeIssues] equipment_id: int = Field(alias="equipment-id") enabled: bool = Field() diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index f28c00f..d8b6bc9 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -25,11 +25,14 @@ FilterWhyOn, HeaterMode, HeaterState, + LightShows, OmniType, + PentairShow, PumpState, RelayState, RelayWhyOn, ValveActuatorState, + ZodiacShow, ) from .exceptions import OmniParsingException @@ -191,15 +194,15 @@ class TelemetryColorLogicLight(BaseModel): omni_type: OmniType = OmniType.CL_LIGHT system_id: int = Field(alias="@systemId") - state: ColorLogicPowerState | int = Field(alias="@lightState") - show: int = Field(alias="@currentShow") - speed: ColorLogicSpeed | int = Field(alias="@speed") - brightness: ColorLogicBrightness | int = Field(alias="@brightness") + state: ColorLogicPowerState = Field(alias="@lightState") + show: LightShows = Field(alias="@currentShow") + speed: ColorLogicSpeed = Field(alias="@speed") + brightness: ColorLogicBrightness = Field(alias="@brightness") special_effect: int = Field(alias="@specialEffect") def show_name( self, model: ColorLogicLightType, v2: bool, pretty: bool = False - ) -> ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2 | int: + ) -> ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2 | PentairShow | ZodiacShow | int: """Get the current light show depending on the light type. Returns: @@ -215,6 +218,10 @@ def show_name( if v2: return ColorLogicShowUCLV2(self.show) return ColorLogicShowUCL(self.show) + case ColorLogicLightType.PENTAIR_COLOR: + return PentairShow(self.show) + case ColorLogicLightType.ZODIAC_COLOR: + return ZodiacShow(self.show) return self.show # Return raw int if type is unknown diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index 58b2923..e218373 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -1,10 +1,8 @@ import logging -from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.api import OmniLogicAPI from pyomnilogic_local.backyard import Backyard from pyomnilogic_local.models import MSPConfig, Telemetry -from pyomnilogic_local.omnitypes import OmniType from pyomnilogic_local.system import System _LOGGER = logging.getLogger(__name__) @@ -20,8 +18,6 @@ class OmniLogic: system: System backyard: Backyard - equipment: dict[int, OmniEquipment] = {} - def __init__(self, host: str, port: int = 10444) -> None: self.host = host self.port = port @@ -50,14 +46,17 @@ async def refresh_telemetry(self) -> None: def _update_equipment(self) -> None: """Update equipment objects based on the latest MSPConfig and Telemetry data.""" - _LOGGER.debug("Updating ColorLogic Light equipment data") - if not hasattr(self, "mspconfig") or self.mspconfig is None: _LOGGER.debug("No MSPConfig data available; skipping equipment update") return - for _, equipment_mspconfig in self.mspconfig: - if equipment_mspconfig.omni_type == OmniType.SYSTEM: - self.system = System(equipment_mspconfig) - if equipment_mspconfig.omni_type == OmniType.BACKYARD: - self.backyard = Backyard(equipment_mspconfig, self.telemetry) + try: + self.system.update_config(self.mspconfig.system) + except AttributeError: + self.system = System(self.mspconfig.system) + + try: + self.backyard.update_config(self.mspconfig.backyard) + self.backyard.update_telemetry(self.telemetry) + except AttributeError: + self.backyard = Backyard(self._api, self.mspconfig.backyard, self.telemetry) diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index 4697757..96f3236 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -208,7 +208,7 @@ class ColorLogicBrightness(IntEnum, PrettyEnum): ONE_HUNDRED_PERCENT = 4 -type ColorLogicShow = ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2 +type LightShows = ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2 | PentairShow | ZodiacShow class ColorLogicShow25(IntEnum, PrettyEnum): @@ -292,6 +292,38 @@ class ColorLogicShowUCLV2(IntEnum, PrettyEnum): BRIGHT_YELLOW = 26 +class PentairShow(IntEnum, PrettyEnum): + SAM = 0 + PARTY = 1 + ROMANCE = 2 + CARIBBEAN = 3 + AMERICAN = 4 + CALIFORNIA_SUNSET = 5 + ROYAL = 6 + BLUE = 7 + GREEN = 8 + RED = 9 + WHITE = 10 + MAGENTA = 11 + + +class ZodiacShow(IntEnum, PrettyEnum): + ALPINE_WHITE = 0 + SKY_BLUE = 1 + COBALT_BLUE = 2 + CARIBBEAN_BLUE = 3 + SPRING_GREEN = 4 + EMERALD_GREEN = 5 + EMERALD_ROSE = 6 + MAGENTA = 7 + VIOLET = 8 + SLOW_COLOR_SPLASH = 9 + FAST_COLOR_SPLASH = 10 + AMERICA_THE_BEAUTIFUL = 11 + FAT_TUESDAY = 12 + DISCO_TECH = 13 + + class ColorLogicPowerState(IntEnum, PrettyEnum): OFF = 0 POWERING_OFF = 1 @@ -306,6 +338,8 @@ class ColorLogicLightType(StrEnum, PrettyEnum): FOUR_ZERO = "COLOR_LOGIC_4_0" TWO_FIVE = "COLOR_LOGIC_2_5" SAM = "COLOR_LOGIC_SAM" + PENTAIR_COLOR = "CL_P_COLOR" + ZODIAC_COLOR = "CL_Z_COLOR" def __str__(self) -> str: return ColorLogicLightType[self.name].value diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py new file mode 100644 index 0000000..fd703ee --- /dev/null +++ b/pyomnilogic_local/pump.py @@ -0,0 +1,7 @@ +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPPump +from pyomnilogic_local.models.telemetry import TelemetryPump + + +class Pump(OmniEquipment[MSPPump, TelemetryPump]): + """Represents a pump in the OmniLogic system.""" diff --git a/pyomnilogic_local/relay.py b/pyomnilogic_local/relay.py index 48fe4f0..02d2990 100644 --- a/pyomnilogic_local/relay.py +++ b/pyomnilogic_local/relay.py @@ -1,5 +1,7 @@ from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPRelay +from pyomnilogic_local.models.telemetry import TelemetryRelay -class Relay(OmniEquipment): +class Relay(OmniEquipment[MSPRelay, TelemetryRelay]): """Represents a relay in the OmniLogic system.""" diff --git a/pyomnilogic_local/sensor.py b/pyomnilogic_local/sensor.py index 9d18ee9..6bfcf06 100644 --- a/pyomnilogic_local/sensor.py +++ b/pyomnilogic_local/sensor.py @@ -1,5 +1,10 @@ from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPSensor -class Sensor(OmniEquipment): - """Represents a sensor in the OmniLogic system.""" +class Sensor(OmniEquipment[MSPSensor, None]): + """Represents a sensor in the OmniLogic system. + + Note: Sensors don't have their own telemetry - they contribute data to + other equipment (like BoW, Backyard, Heaters, etc.) + """ diff --git a/pyomnilogic_local/system.py b/pyomnilogic_local/system.py index 162c98f..7ef3533 100644 --- a/pyomnilogic_local/system.py +++ b/pyomnilogic_local/system.py @@ -1,5 +1,24 @@ -from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPSystem -class System(OmniEquipment): - pass +class System: + """Represents the main system equipment in the OmniLogic system.""" + + def __init__(self, mspconfig: MSPSystem) -> None: + # self.vsp_speed_format = mspconfig.vsp_speed_format + # self.units = mspconfig.units + self.update_config(mspconfig) + + @property + def vsp_speed_format(self) -> str | None: + """The VSP speed format of the system.""" + return self.mspconfig.vsp_speed_format + + @property + def units(self) -> str | None: + """The units of the system.""" + return self.mspconfig.units + + def update_config(self, mspconfig: MSPSystem) -> None: + """Update the configuration data for the equipment.""" + self.mspconfig = mspconfig diff --git a/pyomnilogic_local/util.py b/pyomnilogic_local/util.py index b2b0cfb..32974b2 100644 --- a/pyomnilogic_local/util.py +++ b/pyomnilogic_local/util.py @@ -7,6 +7,10 @@ from typing_extensions import Self +class OmniLogicLocalError(Exception): + """Base exception for python-omnilogic-local.""" + + class PrettyEnum(Enum): def pretty(self) -> str: return self.name.replace("_", " ").title() diff --git a/pyproject.toml b/pyproject.toml index 9a1c82e..a28d4dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,8 @@ readme = "README.md" requires-python = ">=3.12,<4.0.0" dependencies = [ "pydantic >=2.0.0,<3.0.0", - "xmltodict >=0.13.0,<2.0.0", "click >=8.0.0,<8.4.0", + "xmltodict >=1.0.2,<2.0.0", ] [project.scripts] diff --git a/uv.lock b/uv.lock index 9d8e905..d4e1f2b 100644 --- a/uv.lock +++ b/uv.lock @@ -471,7 +471,7 @@ requires-dist = [ { name = "click", specifier = ">=8.0.0,<8.4.0" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "scapy", marker = "extra == 'cli'", specifier = ">=2.6.1,<3.0.0" }, - { name = "xmltodict", specifier = ">=0.13.0,<2.0.0" }, + { name = "xmltodict", specifier = ">=1.0.2,<2.0.0" }, ] provides-extras = ["cli"] From 994ebb536cdd41935a5dc36273db600d082317ff Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:30:00 -0500 Subject: [PATCH 05/61] feat: add EquipmentDict for dual-index equipment access - Create EquipmentDict collection supporting lookup by name or system_id - Replace list[Equipment] with EquipmentDict in Backyard and Bow classes - Add validation for equipment identifiers and duplicate name warnings - Refactor OmniEquipment to call update() from __init__ for proper hook execution - Add _update_equipment() methods to Backyard and Bow for child equipment updates - Update items() to return (system_id, name, item) tuples This enables intuitive equipment access: backyard.bow["Pool"] or backyard.bow[3] while allowing compatibility with iteration and length operations. --- pyomnilogic_local/_base.py | 5 +--- pyomnilogic_local/backyard.py | 39 +++++++++++++++++++------- pyomnilogic_local/bow.py | 51 +++++++++++++++++----------------- pyomnilogic_local/omnilogic.py | 3 +- 4 files changed, 57 insertions(+), 41 deletions(-) diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py index 7030e1c..cda3a73 100644 --- a/pyomnilogic_local/_base.py +++ b/pyomnilogic_local/_base.py @@ -37,10 +37,7 @@ def __init__(self, _api: OmniLogicAPI, mspconfig: MSPConfigT, telemetry: Telemet """ self._api = _api - self.update_config(mspconfig) - - if telemetry is not None: - self.update_telemetry(telemetry) + self.update(mspconfig, telemetry) @property def bow_id(self) -> int | None: diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py index 4b6cb75..72d01f7 100644 --- a/pyomnilogic_local/backyard.py +++ b/pyomnilogic_local/backyard.py @@ -1,4 +1,7 @@ +import logging + from pyomnilogic_local.api.api import OmniLogicAPI +from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.models.mspconfig import MSPBackyard from pyomnilogic_local.models.telemetry import Telemetry, TelemetryBackyard @@ -8,42 +11,58 @@ from .relay import Relay from .sensor import Sensor +_LOGGER = logging.getLogger(__name__) + class Backyard(OmniEquipment[MSPBackyard, TelemetryBackyard]): """Represents the backyard equipment in the OmniLogic system.""" - bow: list[Bow] = [] - lights: list[ColorLogicLight] = [] - relays: list[Relay] = [] - sensors: list[Sensor] = [] + bow: EquipmentDict[Bow] = EquipmentDict() + lights: EquipmentDict[ColorLogicLight] = EquipmentDict() + relays: EquipmentDict[Relay] = EquipmentDict() + sensors: EquipmentDict[Sensor] = EquipmentDict() def __init__(self, _api: OmniLogicAPI, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: super().__init__(_api, mspconfig, telemetry) + def _update_equipment(self, mspconfig: MSPBackyard, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + if telemetry is None: + _LOGGER.warning("No telemetry provided to update Backyard equipment.") + return self._update_bows(mspconfig, telemetry) + self._update_lights(mspconfig, telemetry) self._update_relays(mspconfig, telemetry) self._update_sensors(mspconfig, telemetry) def _update_bows(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: """Update the bows based on the MSP configuration.""" if mspconfig.bow is None: - self.bow = [] + self.bow = EquipmentDict() + return + + self.bow = EquipmentDict([Bow(self._api, bow, telemetry) for bow in mspconfig.bow]) + + def _update_lights(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + """Update the lights based on the MSP configuration.""" + if mspconfig.colorlogic_light is None: + self.lights = EquipmentDict() return - self.bow = [Bow(self._api, bow, telemetry) for bow in mspconfig.bow] + self.lights = EquipmentDict([ColorLogicLight(self._api, 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.""" if mspconfig.relay is None: - self.relays = [] + self.relays = EquipmentDict() return - self.relays = [Relay(self._api, relay, telemetry) for relay in mspconfig.relay] + self.relays = EquipmentDict([Relay(self._api, relay, telemetry) for relay in mspconfig.relay]) def _update_sensors(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: """Update the sensors based on the MSP configuration.""" if mspconfig.sensor is None: - self.sensors = [] + self.sensors = EquipmentDict() return - self.sensors = [Sensor(self._api, sensor, telemetry) for sensor in mspconfig.sensor] + self.sensors = EquipmentDict([Sensor(self._api, sensor, telemetry) for sensor in mspconfig.sensor]) diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index 93ef5fb..7dfba4d 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -1,6 +1,7 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.api.api import OmniLogicAPI from pyomnilogic_local.chlorinator import Chlorinator +from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.colorlogiclight import _LOGGER, ColorLogicLight from pyomnilogic_local.csad import CSAD from pyomnilogic_local.filter import Filter @@ -15,14 +16,14 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): """Represents a bow in the OmniLogic system.""" - filters: list[Filter] = [] + filters: EquipmentDict[Filter] = EquipmentDict() heater: Heater | None = None - relays: list[Relay] = [] - sensors: list[Sensor] = [] - lights: list[ColorLogicLight] = [] - pumps: list[Pump] = [] + relays: EquipmentDict[Relay] = EquipmentDict() + sensors: EquipmentDict[Sensor] = EquipmentDict() + lights: EquipmentDict[ColorLogicLight] = EquipmentDict() + pumps: EquipmentDict[Pump] = EquipmentDict() chlorinator: Chlorinator | None = None - csads: list[CSAD] = [] + csads: EquipmentDict[CSAD] = EquipmentDict() def __init__(self, _api: OmniLogicAPI, mspconfig: MSPBoW, telemetry: Telemetry) -> None: super().__init__(_api, mspconfig, telemetry) @@ -37,21 +38,21 @@ def _update_equipment(self, mspconfig: MSPBoW, telemetry: Telemetry | None) -> N if telemetry is None: _LOGGER.warning("No telemetry provided to update Bow equipment.") return - self._update_filters(self.mspconfig, telemetry) - self._update_heater(self.mspconfig, telemetry) - self._update_sensors(self.mspconfig, telemetry) - self._update_lights(self.mspconfig, telemetry) - self._update_pumps(self.mspconfig, telemetry) - self._update_chlorinators(self.mspconfig, telemetry) - self._update_csads(self.mspconfig, telemetry) + self._update_filters(mspconfig, telemetry) + self._update_heater(mspconfig, telemetry) + self._update_sensors(mspconfig, telemetry) + self._update_lights(mspconfig, telemetry) + self._update_pumps(mspconfig, telemetry) + self._update_chlorinators(mspconfig, telemetry) + self._update_csads(mspconfig, telemetry) def _update_filters(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the filters based on the MSP configuration.""" if mspconfig.filter is None: - self.filters = [] + self.filters = EquipmentDict() return - self.filters = [Filter(self._api, filter_, telemetry) for filter_ in mspconfig.filter] + self.filters = EquipmentDict([Filter(self._api, filter_, telemetry) for filter_ in mspconfig.filter]) def _update_heater(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the heater based on the MSP configuration.""" @@ -64,34 +65,34 @@ def _update_heater(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: def _update_relays(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the relays based on the MSP configuration.""" if mspconfig.relay is None: - self.relays = [] + self.relays = EquipmentDict() return - self.relays = [Relay(self._api, relay, telemetry) for relay in mspconfig.relay] + self.relays = EquipmentDict([Relay(self._api, relay, telemetry) for relay in mspconfig.relay]) def _update_sensors(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the sensors based on the MSP configuration.""" if mspconfig.sensor is None: - self.sensors = [] + self.sensors = EquipmentDict() return - self.sensors = [Sensor(self._api, sensor, telemetry) for sensor in mspconfig.sensor] + self.sensors = EquipmentDict([Sensor(self._api, sensor, telemetry) for sensor in mspconfig.sensor]) def _update_lights(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the lights based on the MSP configuration.""" if mspconfig.colorlogic_light is None: - self.lights = [] + self.lights = EquipmentDict() return - self.lights = [ColorLogicLight(self._api, light, telemetry) for light in mspconfig.colorlogic_light] + self.lights = EquipmentDict([ColorLogicLight(self._api, 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.""" if mspconfig.pump is None: - self.pumps = [] + self.pumps = EquipmentDict() return - self.pumps = [Pump(self._api, pump, telemetry) for pump in mspconfig.pump] + self.pumps = EquipmentDict([Pump(self._api, pump, telemetry) for pump in mspconfig.pump]) def _update_chlorinators(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the chlorinators based on the MSP configuration.""" @@ -104,7 +105,7 @@ def _update_chlorinators(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: def _update_csads(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the CSADs based on the MSP configuration.""" if mspconfig.csad is None: - self.csads = [] + self.csads = EquipmentDict() return - self.csads = [CSAD(self._api, csad, telemetry) for csad in mspconfig.csad] + self.csads = EquipmentDict([CSAD(self._api, csad, telemetry) for csad in mspconfig.csad]) diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index e218373..a2da5d0 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -56,7 +56,6 @@ def _update_equipment(self) -> None: self.system = System(self.mspconfig.system) try: - self.backyard.update_config(self.mspconfig.backyard) - self.backyard.update_telemetry(self.telemetry) + self.backyard.update(self.mspconfig.backyard, self.telemetry) except AttributeError: self.backyard = Backyard(self._api, self.mspconfig.backyard, self.telemetry) From 086426fa1106137163fafcbd505b0c215022efc8 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:41:05 -0500 Subject: [PATCH 06/61] fix: add new collections.py file --- pyomnilogic_local/collections.py | 388 +++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 pyomnilogic_local/collections.py diff --git a/pyomnilogic_local/collections.py b/pyomnilogic_local/collections.py new file mode 100644 index 0000000..e888900 --- /dev/null +++ b/pyomnilogic_local/collections.py @@ -0,0 +1,388 @@ +"""Custom collection types for OmniLogic equipment management.""" + +import logging +from collections import Counter +from collections.abc import Iterator +from typing import Any, Generic, TypeVar, overload + +from pyomnilogic_local._base import OmniEquipment + +_LOGGER = logging.getLogger(__name__) + +# Track which duplicate names we've already warned about to avoid log spam +_WARNED_DUPLICATE_NAMES: set[str] = set() + +# Type variable for equipment types +T = TypeVar("T", bound=OmniEquipment[Any, Any]) + + +class EquipmentDict(Generic[T]): + """A dictionary-like collection that supports lookup by both name and system_id. + + This collection allows accessing equipment using either their name (str) or + system_id (int), providing flexible and intuitive access patterns. + + Type Safety: + The lookup key type determines the lookup method: + - str keys lookup by equipment name + - int keys lookup by equipment system_id + + Examples: + >>> # Create collection from list of equipment + >>> bows = EquipmentDict([pool_bow, spa_bow]) + >>> + >>> # Access by name (string key) + >>> pool = bows["Pool"] + >>> + >>> # Access by system_id (integer key) + >>> pool = bows[3] + >>> + >>> # Explicit methods for clarity + >>> pool = bows.get_by_name("Pool") + >>> pool = bows.get_by_id(3) + >>> + >>> # Standard dict operations + >>> for bow in bows: + ... print(bow.name) + >>> len(bows) + >>> if "Pool" in bows: + ... print("Pool exists") + + Note: + If an equipment item has a name that looks like a number (e.g., "123"), + you must use an actual int type to lookup by system_id, as string keys + always lookup by name. This type-based differentiation prevents ambiguity. + """ + + def __init__(self, items: list[T] | None = None) -> None: + """Initialize the equipment collection. + + Args: + items: Optional list of equipment items to populate the collection. + + Raises: + ValueError: If any item has neither a system_id nor a name. + """ + self._items: list[T] = items if items is not None else [] + self._validate() + + def _validate(self) -> None: + """Validate the equipment collection. + + Checks for: + 1. Items without both system_id and name (raises ValueError) + 2. Duplicate names (logs warning once per unique duplicate) + + Raises: + ValueError: If any item has neither a system_id nor a name. + """ + # Check for items with no system_id AND no name + if invalid_items := [item for item in self._items if item.system_id is None and item.name is None]: + raise ValueError( + f"Equipment collection contains {len(invalid_items)} item(s) " + "with neither a system_id nor a name. All equipment must have " + "at least one identifier for addressing." + ) + + # 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) + + @property + def _by_name(self) -> dict[str, T]: + """Dynamically build name-to-equipment mapping.""" + return {item.name: item for item in self._items if item.name is not None} + + @property + def _by_id(self) -> dict[int, T]: + """Dynamically build system_id-to-equipment mapping.""" + return {item.system_id: item for item in self._items if item.system_id is not None} + + @overload + def __getitem__(self, key: str) -> T: ... + + @overload + def __getitem__(self, key: int) -> T: ... + + def __getitem__(self, key: str | int) -> T: + """Get equipment by name (str) or system_id (int). + + Args: + key: Equipment name (str) or system_id (int) + + Returns: + The equipment item matching the key + + Raises: + KeyError: If no equipment matches the key + TypeError: If key is not str or int + + Examples: + >>> bows["Pool"] # Lookup by name + >>> bows[3] # Lookup by system_id + """ + if isinstance(key, str): + return self._by_name[key] + if isinstance(key, int): + return self._by_id[key] + + raise TypeError(f"Key must be str or int, got {type(key).__name__}") + + def __setitem__(self, key: str | int, value: T) -> None: + """Add or update equipment in the collection. + + The key is only used to determine the operation type (add vs update). + The actual name and system_id are taken from the equipment object itself. + + Args: + key: Equipment name (str) or system_id (int) - must match the equipment's values + value: Equipment item to add or update + + Raises: + TypeError: If key is not str or int + ValueError: If key doesn't match the equipment's name or system_id + + Examples: + >>> # Add by name + >>> bows["Pool"] = new_pool_bow + >>> # Add by system_id + >>> bows[3] = new_pool_bow + """ + if isinstance(key, str): + if value.name != key: + raise ValueError(f"Equipment name '{value.name}' does not match key '{key}'") + elif isinstance(key, int): + if value.system_id != key: + raise ValueError(f"Equipment system_id {value.system_id} does not match key {key}") + else: + raise TypeError(f"Key must be str or int, got {type(key).__name__}") + + # Check if we're updating an existing item (prioritize system_id) + existing_item = None + if value.system_id and value.system_id in self._by_id: + existing_item = self._by_id[value.system_id] + elif value.name and value.name in self._by_name: + existing_item = self._by_name[value.name] + + if existing_item: + # Replace existing item in place + idx = self._items.index(existing_item) + self._items[idx] = value + else: + # Add new item + self._items.append(value) + + # Validate after modification + self._validate() + + def __delitem__(self, key: str | int) -> None: + """Remove equipment from the collection. + + Args: + key: Equipment name (str) or system_id (int) + + Raises: + KeyError: If no equipment matches the key + TypeError: If key is not str or int + + Examples: + >>> del bows["Pool"] # Remove by name + >>> del bows[3] # Remove by system_id + """ + # First, get the item to remove + item = self[key] # This will raise KeyError if not found + + # Remove from the list (indexes rebuild automatically via properties) + self._items.remove(item) + + def __contains__(self, key: str | int) -> bool: + """Check if equipment exists by name (str) or system_id (int). + + Args: + key: Equipment name (str) or system_id (int) + + Returns: + True if equipment exists, False otherwise + + Examples: + >>> if "Pool" in bows: + ... print("Pool exists") + >>> if 3 in bows: + ... print("System ID 3 exists") + """ + if isinstance(key, str): + return key in self._by_name + if isinstance(key, int): + return key in self._by_id + + return False + + def __iter__(self) -> Iterator[T]: + """Iterate over all equipment items in the collection. + + Returns: + Iterator over equipment items + + Examples: + >>> for bow in bows: + ... print(bow.name) + """ + return iter(self._items) + + def __len__(self) -> int: + """Get the number of equipment items in the collection. + + Returns: + Number of items + + Examples: + >>> len(bows) + 2 + """ + return len(self._items) + + def __repr__(self) -> str: + """Get string representation of the collection. + + Returns: + String representation showing item count and names + """ + names = [f"" for item in self._items] + # names = [item.name or f"" for item in self._items] + return f"EquipmentDict({names})" + + def append(self, item: T) -> None: + """Add or update equipment in the collection (list-like interface). + + If equipment with the same system_id or name already exists, it will be + replaced. System_id is checked first as it's the more reliable unique identifier. + + Args: + item: Equipment item to add or update + + Examples: + >>> # Add new equipment + >>> bows.append(new_pool_bow) + >>> + >>> # Update existing equipment (replaces if system_id or name matches) + >>> bows.append(updated_pool_bow) + """ + # Check if we're updating an existing item (prioritize system_id as it's guaranteed unique) + existing_item = None + if item.system_id and item.system_id in self._by_id: + existing_item = self._by_id[item.system_id] + elif item.name and item.name in self._by_name: + existing_item = self._by_name[item.name] + + if existing_item: + # Replace existing item in place + idx = self._items.index(existing_item) + self._items[idx] = item + else: + # Add new item + self._items.append(item) + + # Validate after modification + self._validate() + + def get_by_name(self, name: str) -> T | None: + """Get equipment by name with explicit method (returns None if not found). + + Args: + name: Equipment name + + Returns: + Equipment item or None if not found + + Examples: + >>> pool = bows.get_by_name("Pool") + >>> if pool is not None: + ... await pool.filters[0].turn_on() + """ + return self._by_name.get(name) + + def get_by_id(self, system_id: int) -> T | None: + """Get equipment by system_id with explicit method (returns None if not found). + + Args: + system_id: Equipment system_id + + Returns: + Equipment item or None if not found + + Examples: + >>> pool = bows.get_by_id(3) + >>> if pool is not None: + ... print(pool.name) + """ + return self._by_id.get(system_id) + + def get(self, key: str | int, default: T | None = None) -> T | None: + """Get equipment by name or system_id with optional default. + + Args: + key: Equipment name (str) or system_id (int) + default: Default value to return if key not found + + Returns: + Equipment item or default if not found + + Examples: + >>> pool = bows.get("Pool") + >>> pool = bows.get(3) + >>> pool = bows.get("NonExistent", default=None) + """ + try: + return self[key] + except KeyError: + return default + + def keys(self) -> list[str]: + """Get list of all equipment names. + + Returns: + List of equipment names (excluding items without names) + + Examples: + >>> bows.keys() + ['Pool', 'Spa'] + """ + return list(self._by_name.keys()) + + def values(self) -> list[T]: + """Get list of all equipment items. + + Returns: + List of equipment items + + Examples: + >>> for equipment in bows.values(): + ... print(equipment.name) + """ + return self._items.copy() + + def items(self) -> list[tuple[int | None, str | None, T]]: + """Get list of (system_id, name, equipment) tuples. + + Returns: + List of (system_id, name, equipment) tuples where both system_id + and name can be None (though at least one must be set per validation). + + Examples: + >>> for system_id, name, bow in bows.items(): + ... print(f"ID: {system_id}, Name: {name}") + """ + return [(item.system_id, item.name, item) for item in self._items] From bb37125f167229efcc516a450c35b21be050a74d Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:02:33 -0500 Subject: [PATCH 07/61] feat: add custom exception classes for better error handling - Add OmniEquipmentNotReadyError for equipment in non-ready states - Add OmniEquipmentNotInitializedError for uninitialized equipment - Add OmniConnectionError for controller communication failures - Export all exceptions in __init__.py for easy importing - Replace ValueError with OmniEquipmentNotInitializedError in ColorLogicLight methods All exceptions inherit from OmniLogicLocalError base class and include comprehensive docstrings with usage examples. --- pyomnilogic_local/__init__.py | 14 ++++++++++++- pyomnilogic_local/colorlogiclight.py | 7 ++++--- pyomnilogic_local/util.py | 31 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/pyomnilogic_local/__init__.py b/pyomnilogic_local/__init__.py index 1a8d56d..5aeb140 100644 --- a/pyomnilogic_local/__init__.py +++ b/pyomnilogic_local/__init__.py @@ -1,3 +1,15 @@ from .omnilogic import OmniLogic +from .util import ( + OmniConnectionError, + OmniEquipmentNotInitializedError, + OmniEquipmentNotReadyError, + OmniLogicLocalError, +) -__all__ = ["OmniLogic"] +__all__ = [ + "OmniLogic", + "OmniLogicLocalError", + "OmniEquipmentNotReadyError", + "OmniEquipmentNotInitializedError", + "OmniConnectionError", +] diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index fd4a4b4..220cbf9 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -11,6 +11,7 @@ ColorLogicSpeed, LightShows, ) +from pyomnilogic_local.util import OmniEquipmentNotInitializedError _LOGGER = logging.getLogger(__name__) @@ -70,13 +71,13 @@ def special_effect(self) -> int: async def turn_on(self) -> None: """Turns the light on.""" if self.bow_id is None or self.system_id is None: - raise ValueError("Cannot turn on light: bow_id or system_id is None") + raise OmniEquipmentNotInitializedError("Cannot turn on light: bow_id or system_id is None") await self._api.async_set_equipment(self.bow_id, self.system_id, True) async def turn_off(self) -> None: """Turns the light off.""" if self.bow_id is None or self.system_id is None: - raise ValueError("Cannot turn off light: bow_id or system_id is None") + raise OmniEquipmentNotInitializedError("Cannot turn off light: bow_id or system_id is None") await self._api.async_set_equipment(self.bow_id, self.system_id, False) async def set_show( @@ -102,7 +103,7 @@ async def set_show( brightness = ColorLogicBrightness.ONE_HUNDRED_PERCENT if self.bow_id is None or self.system_id is None: - raise ValueError("Cannot set light show: bow_id or system_id is None") + raise OmniEquipmentNotInitializedError("Cannot set light show: bow_id or system_id is None") await self._api.async_set_light_show( self.bow_id, diff --git a/pyomnilogic_local/util.py b/pyomnilogic_local/util.py index 32974b2..ab0fce5 100644 --- a/pyomnilogic_local/util.py +++ b/pyomnilogic_local/util.py @@ -11,6 +11,37 @@ class OmniLogicLocalError(Exception): """Base exception for python-omnilogic-local.""" +class OmniEquipmentNotReadyError(OmniLogicLocalError): + """Raised when equipment cannot accept commands due to its current state. + + Examples: + - Light in FIFTEEN_SECONDS_WHITE state + - Light in CHANGING_SHOW state + - Light in POWERING_OFF state + - Light in COOLDOWN state + - Equipment performing initialization or calibration + """ + + +class OmniEquipmentNotInitializedError(OmniLogicLocalError): + """Raised when equipment has not been properly initialized. + + This typically occurs when required identifiers (bow_id or system_id) are None, + indicating the equipment hasn't been populated from telemetry data yet. + """ + + +class OmniConnectionError(OmniLogicLocalError): + """Raised when communication with the OmniLogic controller fails. + + Examples: + - UDP socket timeout + - Network unreachable + - Invalid response from controller + - Protocol errors + """ + + class PrettyEnum(Enum): def pretty(self) -> str: return self.name.replace("_", " ").title() From 5bce393e986748d11126cbf10cb25e200f4a0681 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:56:56 -0500 Subject: [PATCH 08/61] feat: add @auto_refresh decorator with smart timestamp-based refresh system - Implement @auto_refresh decorator that automatically refreshes equipment state after control methods - Add update_if_older_than() with asyncio.Lock to prevent redundant API calls - Refactor equipment classes to pass OmniLogic instance instead of OmniLogicAPI for explicit dependency injection --- pyomnilogic_local/_base.py | 18 ++++-- pyomnilogic_local/backyard.py | 17 +++-- pyomnilogic_local/bow.py | 26 ++++---- pyomnilogic_local/colorlogiclight.py | 18 ++++-- pyomnilogic_local/decorators.py | 96 ++++++++++++++++++++++++++++ pyomnilogic_local/omnilogic.py | 74 ++++++++++++++++----- 6 files changed, 203 insertions(+), 46 deletions(-) create mode 100644 pyomnilogic_local/decorators.py diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py index cda3a73..b3aef7f 100644 --- a/pyomnilogic_local/_base.py +++ b/pyomnilogic_local/_base.py @@ -1,10 +1,13 @@ import logging -from typing import Generic, TypeVar, cast +from typing import TYPE_CHECKING, Generic, TypeVar, cast from pyomnilogic_local.api.api import OmniLogicAPI from pyomnilogic_local.models import MSPEquipmentType, Telemetry from pyomnilogic_local.models.telemetry import TelemetryType +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + # Define type variables for generic equipment types MSPConfigT = TypeVar("MSPConfigT", bound=MSPEquipmentType) TelemetryT = TypeVar("TelemetryT", bound=TelemetryType | None) @@ -27,18 +30,23 @@ class OmniEquipment(Generic[MSPConfigT, TelemetryT]): # Use a forward reference for the type hint to avoid issues with self-referential generics child_equipment: dict[int, "OmniEquipment[MSPConfigT, TelemetryT]"] - def __init__(self, _api: OmniLogicAPI, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: + def __init__(self, omni: "OmniLogic", mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: """Initialize the equipment with configuration and telemetry data. Args: - _api: The OmniLogic API instance + omni: The OmniLogic instance (parent controller) mspconfig: The MSP configuration for this specific equipment telemetry: The full Telemetry object containing all equipment telemetry """ - self._api = _api + self._omni = omni self.update(mspconfig, telemetry) + @property + def _api(self) -> "OmniLogicAPI": + """Access the OmniLogic API through the parent controller.""" + return self._omni._api # pylint: disable=protected-access + @property def bow_id(self) -> int | None: """The bow ID of the equipment.""" @@ -85,7 +93,7 @@ def update_telemetry(self, telemetry: Telemetry) -> None: # if hasattr(self, "telemetry"): # Extract the specific telemetry for this equipment from the full telemetry object # Note: Some equipment (like sensors) don't have their own telemetry, so this may be None - if specific_telemetry := telemetry.get_telem_by_systemid(self.mspconfig.system_id) is not None: + if (specific_telemetry := telemetry.get_telem_by_systemid(self.mspconfig.system_id)) is not None: self.telemetry = cast(TelemetryT, specific_telemetry) else: self.telemetry = cast(TelemetryT, None) diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py index 72d01f7..53179b2 100644 --- a/pyomnilogic_local/backyard.py +++ b/pyomnilogic_local/backyard.py @@ -1,6 +1,6 @@ import logging +from typing import TYPE_CHECKING -from pyomnilogic_local.api.api import OmniLogicAPI from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.models.mspconfig import MSPBackyard from pyomnilogic_local.models.telemetry import Telemetry, TelemetryBackyard @@ -11,6 +11,9 @@ from .relay import Relay from .sensor import Sensor +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + _LOGGER = logging.getLogger(__name__) @@ -22,8 +25,8 @@ class Backyard(OmniEquipment[MSPBackyard, TelemetryBackyard]): relays: EquipmentDict[Relay] = EquipmentDict() sensors: EquipmentDict[Sensor] = EquipmentDict() - def __init__(self, _api: OmniLogicAPI, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: - super().__init__(_api, mspconfig, telemetry) + def __init__(self, omni: "OmniLogic", mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) def _update_equipment(self, mspconfig: MSPBackyard, telemetry: Telemetry | None) -> None: """Update both the configuration and telemetry data for the equipment.""" @@ -41,7 +44,7 @@ def _update_bows(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: self.bow = EquipmentDict() return - self.bow = EquipmentDict([Bow(self._api, bow, telemetry) for bow in mspconfig.bow]) + self.bow = EquipmentDict([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.""" @@ -49,7 +52,7 @@ def _update_lights(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: self.lights = EquipmentDict() return - self.lights = EquipmentDict([ColorLogicLight(self._api, light, telemetry) for light in mspconfig.colorlogic_light]) + self.lights = EquipmentDict([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.""" @@ -57,7 +60,7 @@ def _update_relays(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: self.relays = EquipmentDict() return - self.relays = EquipmentDict([Relay(self._api, relay, telemetry) for relay in mspconfig.relay]) + self.relays = EquipmentDict([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.""" @@ -65,4 +68,4 @@ def _update_sensors(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: self.sensors = EquipmentDict() return - self.sensors = EquipmentDict([Sensor(self._api, sensor, telemetry) for sensor in mspconfig.sensor]) + self.sensors = EquipmentDict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor]) diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index 7dfba4d..d1a41ec 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -1,5 +1,6 @@ +from typing import TYPE_CHECKING + from pyomnilogic_local._base import OmniEquipment -from pyomnilogic_local.api.api import OmniLogicAPI from pyomnilogic_local.chlorinator import Chlorinator from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.colorlogiclight import _LOGGER, ColorLogicLight @@ -12,6 +13,9 @@ from pyomnilogic_local.relay import Relay from pyomnilogic_local.sensor import Sensor +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): """Represents a bow in the OmniLogic system.""" @@ -25,8 +29,8 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): chlorinator: Chlorinator | None = None csads: EquipmentDict[CSAD] = EquipmentDict() - def __init__(self, _api: OmniLogicAPI, mspconfig: MSPBoW, telemetry: Telemetry) -> None: - super().__init__(_api, mspconfig, telemetry) + def __init__(self, omni: "OmniLogic", mspconfig: MSPBoW, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) @property def equip_type(self) -> str: @@ -52,7 +56,7 @@ def _update_filters(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.filters = EquipmentDict() return - self.filters = EquipmentDict([Filter(self._api, filter_, telemetry) for filter_ in mspconfig.filter]) + self.filters = EquipmentDict([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.""" @@ -60,7 +64,7 @@ def _update_heater(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.heater = None return - self.heater = Heater(self._api, mspconfig.heater, telemetry) + self.heater = Heater(self._omni, mspconfig.heater, telemetry) def _update_relays(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the relays based on the MSP configuration.""" @@ -68,7 +72,7 @@ def _update_relays(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.relays = EquipmentDict() return - self.relays = EquipmentDict([Relay(self._api, relay, telemetry) for relay in mspconfig.relay]) + self.relays = EquipmentDict([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.""" @@ -76,7 +80,7 @@ def _update_sensors(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.sensors = EquipmentDict() return - self.sensors = EquipmentDict([Sensor(self._api, sensor, telemetry) for sensor in mspconfig.sensor]) + self.sensors = EquipmentDict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor]) def _update_lights(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the lights based on the MSP configuration.""" @@ -84,7 +88,7 @@ def _update_lights(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.lights = EquipmentDict() return - self.lights = EquipmentDict([ColorLogicLight(self._api, light, telemetry) for light in mspconfig.colorlogic_light]) + self.lights = EquipmentDict([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.""" @@ -92,7 +96,7 @@ def _update_pumps(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.pumps = EquipmentDict() return - self.pumps = EquipmentDict([Pump(self._api, pump, telemetry) for pump in mspconfig.pump]) + self.pumps = EquipmentDict([Pump(self._omni, pump, telemetry) for pump in mspconfig.pump]) def _update_chlorinators(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the chlorinators based on the MSP configuration.""" @@ -100,7 +104,7 @@ def _update_chlorinators(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.chlorinator = None return - self.chlorinator = Chlorinator(self._api, mspconfig.chlorinator, telemetry) + self.chlorinator = Chlorinator(self._omni, mspconfig.chlorinator, telemetry) def _update_csads(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the CSADs based on the MSP configuration.""" @@ -108,4 +112,4 @@ def _update_csads(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.csads = EquipmentDict() return - self.csads = EquipmentDict([CSAD(self._api, csad, telemetry) for csad in mspconfig.csad]) + self.csads = EquipmentDict([CSAD(self._omni, csad, telemetry) for csad in mspconfig.csad]) diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index 220cbf9..f574ca7 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -1,7 +1,8 @@ import logging +from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment -from pyomnilogic_local.api.api import OmniLogicAPI +from pyomnilogic_local.decorators import auto_refresh from pyomnilogic_local.models.mspconfig import MSPColorLogicLight from pyomnilogic_local.models.telemetry import Telemetry, TelemetryColorLogicLight from pyomnilogic_local.omnitypes import ( @@ -13,14 +14,17 @@ ) from pyomnilogic_local.util import OmniEquipmentNotInitializedError +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + _LOGGER = logging.getLogger(__name__) class ColorLogicLight(OmniEquipment[MSPColorLogicLight, TelemetryColorLogicLight]): """Represents a color logic light.""" - def __init__(self, _api: OmniLogicAPI, mspconfig: MSPColorLogicLight, telemetry: Telemetry) -> None: - super().__init__(_api, mspconfig, telemetry) + def __init__(self, omni: "OmniLogic", mspconfig: MSPColorLogicLight, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) @property def model(self) -> ColorLogicLightType: @@ -68,23 +72,23 @@ def special_effect(self) -> int: """Returns the current special effect.""" return self.telemetry.special_effect + @auto_refresh() async def turn_on(self) -> None: """Turns the light on.""" if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot turn on light: bow_id or system_id is None") await self._api.async_set_equipment(self.bow_id, self.system_id, True) + @auto_refresh() async def turn_off(self) -> None: """Turns the light off.""" if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot turn off light: bow_id or system_id is None") await self._api.async_set_equipment(self.bow_id, self.system_id, False) + @auto_refresh() async def set_show( - self, - show: LightShows | None = None, - speed: ColorLogicSpeed | None = None, - brightness: ColorLogicBrightness | None = None, + self, show: LightShows | None = None, speed: ColorLogicSpeed | None = None, brightness: ColorLogicBrightness | None = None ) -> None: """Sets the light show, speed, and brightness.""" diff --git a/pyomnilogic_local/decorators.py b/pyomnilogic_local/decorators.py new file mode 100644 index 0000000..48093a6 --- /dev/null +++ b/pyomnilogic_local/decorators.py @@ -0,0 +1,96 @@ +"""Decorators for automatic state management in pyomnilogic_local.""" + +import asyncio +import functools +import logging +import time +from collections.abc import Callable +from typing import Any, TypeVar, cast + +_LOGGER = logging.getLogger(__name__) + +F = TypeVar("F", bound=Callable[..., Any]) + + +def auto_refresh( + update_mspconfig: bool = False, + update_telemetry: bool = True, + delay: float = 1.25, +) -> Callable[[F], F]: + """Decorator to automatically refresh OmniLogic state after method execution. + + This decorator will: + 1. Execute the decorated method + 2. Wait for the specified delay (to allow controller to update) + 3. Refresh telemetry/mspconfig if they're older than the post-delay time + + The decorator is lock-safe: if multiple decorated methods are called concurrently, + only one refresh will occur thanks to the update_if_older_than mechanism. + + Args: + update_mspconfig: Whether to refresh MSPConfig after method execution + update_telemetry: Whether to refresh Telemetry after method execution + delay: Time in seconds to wait after method completes before refreshing + + Usage: + @auto_refresh() # Default: telemetry only, 0.25s delay + async def turn_on(self, auto_refresh: bool | None = None): + ... + + @auto_refresh(update_mspconfig=True, delay=0.5) + async def configure(self, auto_refresh: bool | None = None): + ... + + The decorated method can accept an optional `auto_refresh` parameter: + - If None (default): Uses the OmniLogic instance's auto_refresh_enabled setting + - If True: Forces auto-refresh regardless of instance setting + - If False: Disables auto-refresh for this call + """ + + def decorator(func: F) -> F: + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + # Extract the 'auto_refresh' parameter if provided + auto_refresh_param = kwargs.pop("auto_refresh", None) + + # First arg should be 'self' (equipment instance) + if not args: + raise RuntimeError("@auto_refresh decorator requires a method with 'self' parameter") + + self_obj = args[0] + + # Get the OmniLogic instance + # Equipment classes should have _omni attribute pointing to parent OmniLogic + if hasattr(self_obj, "_omni") and self_obj._omni is not None: # pylint: disable=protected-access + omni = self_obj._omni # pylint: disable=protected-access + elif hasattr(self_obj, "auto_refresh_enabled"): + # This IS the OmniLogic instance + omni = self_obj + else: + raise RuntimeError("@auto_refresh decorator requires equipment to have '_omni' attribute or be used on OmniLogic methods") + + # Determine if we should auto-refresh + should_refresh = auto_refresh_param if auto_refresh_param is not None else omni.auto_refresh_enabled + + # Execute the original method + result = await func(*args, **kwargs) + + # Perform auto-refresh if enabled + if should_refresh: + # Wait for the controller to process the change + await asyncio.sleep(delay) + + # Calculate the target time (after delay) + target_time = time.time() + + # Update only if data is older than target time + await omni.update_if_older_than( + telemetry_min_time=target_time if update_telemetry else None, + mspconfig_min_time=target_time if update_mspconfig else None, + ) + + return result + + return cast(F, wrapper) + + return decorator diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index a2da5d0..3c13279 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -1,4 +1,6 @@ +import asyncio import logging +import time from pyomnilogic_local.api import OmniLogicAPI from pyomnilogic_local.backyard import Backyard @@ -9,8 +11,7 @@ class OmniLogic: - poll_mspconfig: bool = True - poll_telemetry: bool = True + auto_refresh_enabled: bool = True mspconfig: MSPConfig telemetry: Telemetry @@ -18,30 +19,69 @@ class OmniLogic: system: System backyard: Backyard + _mspconfig_last_updated: float = 0.0 + _telemetry_last_updated: float = 0.0 + _refresh_lock: asyncio.Lock + def __init__(self, host: str, port: int = 10444) -> None: self.host = host self.port = port self._api = OmniLogicAPI(host, port) + self._refresh_lock = asyncio.Lock() + + async def refresh(self, update_mspconfig: bool = True, update_telemetry: bool = True) -> None: + """Refresh the data from the OmniLogic controller. - async def refresh(self) -> None: - """Refresh the data from the OmniLogic controller.""" - if self.poll_mspconfig: + Args: + update_mspconfig: Whether to fetch and update MSPConfig data + update_telemetry: Whether to fetch and update Telemetry data + """ + if update_mspconfig: self.mspconfig = await self._api.async_get_mspconfig() - if self.poll_telemetry: + self._mspconfig_last_updated = time.time() + if update_telemetry: self.telemetry = await self._api.async_get_telemetry() + self._telemetry_last_updated = time.time() self._update_equipment() - async def refresh_mspconfig(self) -> None: - """Refresh only the MSPConfig data from the OmniLogic controller.""" - self.mspconfig = await self._api.async_get_mspconfig() - self._update_equipment() - - async def refresh_telemetry(self) -> None: - """Refresh only the Telemetry data from the OmniLogic controller.""" - self.telemetry = await self._api.async_get_telemetry() - self._update_equipment() + # async def refresh_mspconfig(self) -> None: + # """Refresh only the MSPConfig data from the OmniLogic controller.""" + # self.mspconfig = await self._api.async_get_mspconfig() + # self._mspconfig_last_updated = time.time() + # self._update_equipment() + + # async def refresh_telemetry(self) -> None: + # """Refresh only the Telemetry data from the OmniLogic controller.""" + # self.telemetry = await self._api.async_get_telemetry() + # self._telemetry_last_updated = time.time() + # self._update_equipment() + + async def update_if_older_than( + self, + telemetry_min_time: float | None = None, + mspconfig_min_time: float | None = None, + ) -> None: + """Update telemetry/mspconfig only if older than specified timestamp. + + This method uses a lock to ensure only one refresh happens at a time. + If another thread/task already updated the data to be newer than required, + this method will skip the update. + + Args: + telemetry_min_time: Update telemetry if last updated before this timestamp + mspconfig_min_time: Update mspconfig if last updated before this timestamp + """ + async with self._refresh_lock: + needs_telemetry = telemetry_min_time and self._telemetry_last_updated < telemetry_min_time + needs_mspconfig = mspconfig_min_time and self._mspconfig_last_updated < mspconfig_min_time + + if needs_telemetry or needs_mspconfig: + await self.refresh( + update_mspconfig=bool(needs_mspconfig), + update_telemetry=bool(needs_telemetry), + ) def _update_equipment(self) -> None: """Update equipment objects based on the latest MSPConfig and Telemetry data.""" @@ -58,4 +98,6 @@ def _update_equipment(self) -> None: try: self.backyard.update(self.mspconfig.backyard, self.telemetry) except AttributeError: - self.backyard = Backyard(self._api, self.mspconfig.backyard, self.telemetry) + self.backyard = Backyard(self, self.mspconfig.backyard, self.telemetry) + + # No need for _set_omni_reference anymore - it's passed in __init__! From 68da82d43a1dc20f2c992ea211f6a6f6122dd26e Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:31:28 -0500 Subject: [PATCH 09/61] refactor: replace @auto_refresh with simpler @dirties_state decorator - Remove complex automatic refresh system in favor of explicit state tracking. - Add dirty flags to OmniLogic and update refresh() with flexible if_dirty/if_older_than/force parameters for better user control. --- pyomnilogic_local/_base.py | 22 ------- pyomnilogic_local/colorlogiclight.py | 8 +-- pyomnilogic_local/decorators.py | 93 +++++++------------------- pyomnilogic_local/omnilogic.py | 98 ++++++++++++++-------------- 4 files changed, 77 insertions(+), 144 deletions(-) diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py index b3aef7f..1fffa00 100644 --- a/pyomnilogic_local/_base.py +++ b/pyomnilogic_local/_base.py @@ -97,25 +97,3 @@ def update_telemetry(self, telemetry: Telemetry) -> None: self.telemetry = cast(TelemetryT, specific_telemetry) else: self.telemetry = cast(TelemetryT, None) - # else: - # raise NotImplementedError("This equipment does not have telemetry data.") - - # def _update_equipment(self, telemetry: Telemetry) -> None: - # pass - - # def _update_equipment(self, telemetry: Telemetry) -> None: - # """Update any child equipment based on the latest MSPConfig and Telemetry data.""" - # for _, equipment_mspconfig in self.mspconfig: - # system_id = equipment_mspconfig.system_id - # if system_id is None: - # _LOGGER.debug("Skipping equipment update: system_id is None: %s", equipment_mspconfig) - # continue - # if system_id in self.child_equipment: - # # Update existing child equipment - # child_equipment = self.child_equipment[system_id] - # if child_equipment is not None: - # child_equipment.update_config(equipment_mspconfig) - # if hasattr(self, "telemetry"): - # child_equipment.update_telemetry(telemetry) - # else: - # equipment = create_equipment(self, equipment_mspconfig, telemetry) diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index f574ca7..44a4633 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment -from pyomnilogic_local.decorators import auto_refresh +from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPColorLogicLight from pyomnilogic_local.models.telemetry import Telemetry, TelemetryColorLogicLight from pyomnilogic_local.omnitypes import ( @@ -72,21 +72,21 @@ def special_effect(self) -> int: """Returns the current special effect.""" return self.telemetry.special_effect - @auto_refresh() + @dirties_state() async def turn_on(self) -> None: """Turns the light on.""" if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot turn on light: bow_id or system_id is None") await self._api.async_set_equipment(self.bow_id, self.system_id, True) - @auto_refresh() + @dirties_state() async def turn_off(self) -> None: """Turns the light off.""" if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot turn off light: bow_id or system_id is None") await self._api.async_set_equipment(self.bow_id, self.system_id, False) - @auto_refresh() + @dirties_state() async def set_show( self, show: LightShows | None = None, speed: ColorLogicSpeed | None = None, brightness: ColorLogicBrightness | None = None ) -> None: diff --git a/pyomnilogic_local/decorators.py b/pyomnilogic_local/decorators.py index 48093a6..cd8ec77 100644 --- a/pyomnilogic_local/decorators.py +++ b/pyomnilogic_local/decorators.py @@ -1,9 +1,7 @@ -"""Decorators for automatic state management in pyomnilogic_local.""" +"""Decorators for equipment control methods.""" -import asyncio import functools import logging -import time from collections.abc import Callable from typing import Any, TypeVar, cast @@ -12,82 +10,37 @@ F = TypeVar("F", bound=Callable[..., Any]) -def auto_refresh( - update_mspconfig: bool = False, - update_telemetry: bool = True, - delay: float = 1.25, -) -> Callable[[F], F]: - """Decorator to automatically refresh OmniLogic state after method execution. +def dirties_state(mspconfig: bool = False, telemetry: bool = True) -> Callable[[F], F]: + """Mark state as dirty after equipment control methods. - This decorator will: - 1. Execute the decorated method - 2. Wait for the specified delay (to allow controller to update) - 3. Refresh telemetry/mspconfig if they're older than the post-delay time - - The decorator is lock-safe: if multiple decorated methods are called concurrently, - only one refresh will occur thanks to the update_if_older_than mechanism. + This decorator marks the OmniLogic state (telemetry and/or mspconfig) as dirty + after a control method executes, indicating that the cached state is likely + out of sync with reality. Users can then call refresh() to update the state. Args: - update_mspconfig: Whether to refresh MSPConfig after method execution - update_telemetry: Whether to refresh Telemetry after method execution - delay: Time in seconds to wait after method completes before refreshing - - Usage: - @auto_refresh() # Default: telemetry only, 0.25s delay - async def turn_on(self, auto_refresh: bool | None = None): - ... - - @auto_refresh(update_mspconfig=True, delay=0.5) - async def configure(self, auto_refresh: bool | None = None): - ... + mspconfig: Whether to mark mspconfig as dirty (default: False) + telemetry: Whether to mark telemetry as dirty (default: True) - The decorated method can accept an optional `auto_refresh` parameter: - - If None (default): Uses the OmniLogic instance's auto_refresh_enabled setting - - If True: Forces auto-refresh regardless of instance setting - - If False: Disables auto-refresh for this call + Example: + @dirties_state(telemetry=True) + async def turn_on(self) -> None: + await self._api.async_set_equipment(...) """ def decorator(func: F) -> F: @functools.wraps(func) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - # Extract the 'auto_refresh' parameter if provided - auto_refresh_param = kwargs.pop("auto_refresh", None) - - # First arg should be 'self' (equipment instance) - if not args: - raise RuntimeError("@auto_refresh decorator requires a method with 'self' parameter") - - self_obj = args[0] - - # Get the OmniLogic instance - # Equipment classes should have _omni attribute pointing to parent OmniLogic - if hasattr(self_obj, "_omni") and self_obj._omni is not None: # pylint: disable=protected-access - omni = self_obj._omni # pylint: disable=protected-access - elif hasattr(self_obj, "auto_refresh_enabled"): - # This IS the OmniLogic instance - omni = self_obj + async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + # Execute the original function + result = await func(self, *args, **kwargs) + + # Mark state as dirty + if hasattr(self, "_omni"): + if telemetry: + self._omni._telemetry_dirty = True # pylint: disable=protected-access + if mspconfig: + self._omni._mspconfig_dirty = True # pylint: disable=protected-access else: - raise RuntimeError("@auto_refresh decorator requires equipment to have '_omni' attribute or be used on OmniLogic methods") - - # Determine if we should auto-refresh - should_refresh = auto_refresh_param if auto_refresh_param is not None else omni.auto_refresh_enabled - - # Execute the original method - result = await func(*args, **kwargs) - - # Perform auto-refresh if enabled - if should_refresh: - # Wait for the controller to process the change - await asyncio.sleep(delay) - - # Calculate the target time (after delay) - target_time = time.time() - - # Update only if data is older than target time - await omni.update_if_older_than( - telemetry_min_time=target_time if update_telemetry else None, - mspconfig_min_time=target_time if update_mspconfig else None, - ) + _LOGGER.warning("%s does not have _omni reference, cannot mark state as dirty", self.__class__.__name__) return result diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index 3c13279..3d9a968 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -11,8 +11,6 @@ class OmniLogic: - auto_refresh_enabled: bool = True - mspconfig: MSPConfig telemetry: Telemetry @@ -21,6 +19,8 @@ class OmniLogic: _mspconfig_last_updated: float = 0.0 _telemetry_last_updated: float = 0.0 + _mspconfig_dirty: bool = True + _telemetry_dirty: bool = True _refresh_lock: asyncio.Lock def __init__(self, host: str, port: int = 10444) -> None: @@ -30,58 +30,60 @@ def __init__(self, host: str, port: int = 10444) -> None: self._api = OmniLogicAPI(host, port) self._refresh_lock = asyncio.Lock() - async def refresh(self, update_mspconfig: bool = True, update_telemetry: bool = True) -> None: - """Refresh the data from the OmniLogic controller. - - Args: - update_mspconfig: Whether to fetch and update MSPConfig data - update_telemetry: Whether to fetch and update Telemetry data - """ - if update_mspconfig: - self.mspconfig = await self._api.async_get_mspconfig() - self._mspconfig_last_updated = time.time() - if update_telemetry: - self.telemetry = await self._api.async_get_telemetry() - self._telemetry_last_updated = time.time() - - self._update_equipment() - - # async def refresh_mspconfig(self) -> None: - # """Refresh only the MSPConfig data from the OmniLogic controller.""" - # self.mspconfig = await self._api.async_get_mspconfig() - # self._mspconfig_last_updated = time.time() - # self._update_equipment() - - # async def refresh_telemetry(self) -> None: - # """Refresh only the Telemetry data from the OmniLogic controller.""" - # self.telemetry = await self._api.async_get_telemetry() - # self._telemetry_last_updated = time.time() - # self._update_equipment() - - async def update_if_older_than( + async def refresh( self, - telemetry_min_time: float | None = None, - mspconfig_min_time: float | None = None, + *, + mspconfig: bool = True, + telemetry: bool = True, + if_dirty: bool = True, + if_older_than: float = 10.0, + force: bool = False, ) -> None: - """Update telemetry/mspconfig only if older than specified timestamp. - - This method uses a lock to ensure only one refresh happens at a time. - If another thread/task already updated the data to be newer than required, - this method will skip the update. + """Refresh the data from the OmniLogic controller. Args: - telemetry_min_time: Update telemetry if last updated before this timestamp - mspconfig_min_time: Update mspconfig if last updated before this timestamp + mspconfig: Whether to refresh MSPConfig data (if conditions are met) + telemetry: Whether to refresh Telemetry data (if conditions are met) + if_dirty: Only refresh if the data has been marked dirty + if_older_than: Only refresh if data is older than this many seconds + force: Force refresh regardless of dirty flag or age """ async with self._refresh_lock: - needs_telemetry = telemetry_min_time and self._telemetry_last_updated < telemetry_min_time - needs_mspconfig = mspconfig_min_time and self._mspconfig_last_updated < mspconfig_min_time - - if needs_telemetry or needs_mspconfig: - await self.refresh( - update_mspconfig=bool(needs_mspconfig), - update_telemetry=bool(needs_telemetry), - ) + current_time = time.time() + + # Determine if mspconfig needs updating + update_mspconfig = False + if mspconfig: + if force: + update_mspconfig = True + elif if_dirty and self._mspconfig_dirty: + update_mspconfig = True + elif (current_time - self._mspconfig_last_updated) > if_older_than: + update_mspconfig = True + + # Determine if telemetry needs updating + update_telemetry = False + if telemetry: + if force: + update_telemetry = True + elif if_dirty and self._telemetry_dirty: + update_telemetry = True + elif (current_time - self._telemetry_last_updated) > if_older_than: + update_telemetry = True + + # Perform the updates + if update_mspconfig: + self.mspconfig = await self._api.async_get_mspconfig() + self._mspconfig_last_updated = time.time() + self._mspconfig_dirty = False + + if update_telemetry: + self.telemetry = await self._api.async_get_telemetry() + self._telemetry_last_updated = time.time() + self._telemetry_dirty = False + + if update_mspconfig or update_telemetry: + self._update_equipment() def _update_equipment(self) -> None: """Update equipment objects based on the latest MSPConfig and Telemetry data.""" From 864e85483ab48a6f6150309ff37acfbf4a75352f Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:12:02 -0500 Subject: [PATCH 10/61] feat: add turn_on/turn_off methods and is_on/is_ready properties to Filter and Pump - Filter includes run_preset_speed method for LOW/MEDIUM/HIGH presets - All MSPConfig and Telemetry attributes exposed as properties --- pyomnilogic_local/filter.py | 172 ++++++++++++++ pyomnilogic_local/omnitypes.py | 8 +- pyomnilogic_local/pump.py | 125 +++++++++++ tests/test_chlorinator_bitmask.py | 2 + tests/test_chlorinator_multibit.py | 2 + tests/test_filter_pump.py | 349 +++++++++++++++++++++++++++++ tests/test_protocol.py | 2 + 7 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 tests/test_filter_pump.py diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py index dc4053a..43fb5f8 100644 --- a/pyomnilogic_local/filter.py +++ b/pyomnilogic_local/filter.py @@ -1,7 +1,179 @@ from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPFilter from pyomnilogic_local.models.telemetry import TelemetryFilter +from pyomnilogic_local.omnitypes import FilterSpeedPresets, FilterState class Filter(OmniEquipment[MSPFilter, TelemetryFilter]): """Represents a filter in the OmniLogic system.""" + + # Expose MSPConfig attributes + @property + def equip_type(self) -> str: + """The filter type (e.g., FMT_VARIABLE_SPEED_PUMP).""" + return self.mspconfig.equip_type + + @property + def max_percent(self) -> int: + """Maximum pump speed percentage.""" + return self.mspconfig.max_percent + + @property + def min_percent(self) -> int: + """Minimum pump speed percentage.""" + return self.mspconfig.min_percent + + @property + def max_rpm(self) -> int: + """Maximum pump speed in RPM.""" + return self.mspconfig.max_rpm + + @property + def min_rpm(self) -> int: + """Minimum pump speed in RPM.""" + return self.mspconfig.min_rpm + + @property + def priming_enabled(self) -> bool: + """Whether priming is enabled for this filter.""" + return self.mspconfig.priming_enabled + + @property + def low_speed(self) -> int: + """Low speed preset value.""" + return self.mspconfig.low_speed + + @property + def medium_speed(self) -> int: + """Medium speed preset value.""" + return self.mspconfig.medium_speed + + @property + def high_speed(self) -> int: + """High speed preset value.""" + return self.mspconfig.high_speed + + # Expose Telemetry attributes + @property + def state(self) -> FilterState | int: + """Current filter state.""" + return self.telemetry.state + + @property + def speed(self) -> int: + """Current filter speed.""" + return self.telemetry.speed + + @property + def valve_position(self) -> int: + """Current valve position.""" + return self.telemetry.valve_position + + @property + def why_on(self) -> int: + """Reason why the filter is on.""" + return self.telemetry.why_on + + @property + def reported_speed(self) -> int: + """Reported filter speed.""" + return self.telemetry.reported_speed + + @property + def power(self) -> int: + """Current power consumption.""" + return self.telemetry.power + + @property + def last_speed(self) -> int: + """Last speed setting.""" + return self.telemetry.last_speed + + # Computed properties + @property + def is_on(self) -> bool: + """Check if the filter is currently on. + + Returns: + True if filter state is ON (1), False otherwise + """ + return self.state in ( + FilterState.ON, + FilterState.PRIMING, + FilterState.HEATER_EXTEND, + FilterState.CSAD_EXTEND, + FilterState.FILTER_FORCE_PRIMING, + FilterState.FILTER_SUPERCHLORINATE, + ) + + @property + def is_ready(self) -> bool: + """Check if the filter is ready to receive commands. + + A filter is considered ready if it's not in a transitional state like + priming, waiting to turn off, or cooling down. + + Returns: + True if filter can accept commands, False otherwise + """ + return self.state in (FilterState.OFF, FilterState.ON) + + # Control methods + @dirties_state() + async def turn_on(self) -> None: + """Turn the filter on. + + This will turn on the filter at its last used speed setting. + """ + if self.bow_id is None or self.system_id is None: + msg = "Filter bow_id and system_id must be set" + raise ValueError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=self.last_speed, + ) + + @dirties_state() + async def turn_off(self) -> None: + """Turn the filter off.""" + if self.bow_id is None or self.system_id is None: + msg = "Filter bow_id and system_id must be set" + raise ValueError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=False, + ) + + @dirties_state() + async def run_preset_speed(self, speed: FilterSpeedPresets) -> None: + """Run the filter at a preset speed. + + Args: + speed: The preset speed to use (LOW, MEDIUM, or HIGH) + """ + if self.bow_id is None or self.system_id is None: + msg = "Filter bow_id and system_id must be set" + raise ValueError(msg) + + speed_value: int + match speed: + case FilterSpeedPresets.LOW: + speed_value = self.low_speed + case FilterSpeedPresets.MEDIUM: + speed_value = self.medium_speed + case FilterSpeedPresets.HIGH: + speed_value = self.high_speed + case _: + msg = f"Invalid speed preset: {speed}" + raise ValueError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=speed_value, + ) diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index 96f3236..d4fd8fb 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -1,4 +1,4 @@ -from enum import Enum, Flag, IntEnum, StrEnum +from enum import Enum, Flag, IntEnum, StrEnum, auto from .util import PrettyEnum @@ -416,6 +416,12 @@ class FilterWhyOn(IntEnum, PrettyEnum): UNKNOWN_18 = 18 +class FilterSpeedPresets(StrEnum, PrettyEnum): + LOW = auto() + MEDIUM = auto() + HIGH = auto() + + # Heaters class HeaterState(IntEnum, PrettyEnum): OFF = 0 diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index fd703ee..cc64696 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -1,7 +1,132 @@ from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPPump from pyomnilogic_local.models.telemetry import TelemetryPump +from pyomnilogic_local.omnitypes import PumpState class Pump(OmniEquipment[MSPPump, TelemetryPump]): """Represents a pump in the OmniLogic system.""" + + # Expose MSPConfig attributes + @property + def equip_type(self) -> str: + """The pump type (e.g., PMP_VARIABLE_SPEED_PUMP).""" + return self.mspconfig.equip_type + + @property + def function(self) -> str: + """The pump function (e.g., PMP_PUMP, PMP_WATER_FEATURE).""" + return self.mspconfig.function + + @property + def max_percent(self) -> int: + """Maximum pump speed percentage.""" + return self.mspconfig.max_percent + + @property + def min_percent(self) -> int: + """Minimum pump speed percentage.""" + return self.mspconfig.min_percent + + @property + def max_rpm(self) -> int: + """Maximum pump speed in RPM.""" + return self.mspconfig.max_rpm + + @property + def min_rpm(self) -> int: + """Minimum pump speed in RPM.""" + return self.mspconfig.min_rpm + + @property + def priming_enabled(self) -> bool: + """Whether priming is enabled for this pump.""" + return self.mspconfig.priming_enabled + + @property + def low_speed(self) -> int: + """Low speed preset value.""" + return self.mspconfig.low_speed + + @property + def medium_speed(self) -> int: + """Medium speed preset value.""" + return self.mspconfig.medium_speed + + @property + def high_speed(self) -> int: + """High speed preset value.""" + return self.mspconfig.high_speed + + # Expose Telemetry attributes + @property + def state(self) -> PumpState | int: + """Current pump state.""" + return self.telemetry.state + + @property + def speed(self) -> int: + """Current pump speed.""" + return self.telemetry.speed + + @property + def last_speed(self) -> int: + """Last speed setting.""" + return self.telemetry.last_speed + + @property + def why_on(self) -> int: + """Reason why the pump is on.""" + return self.telemetry.why_on + + # Computed properties + @property + def is_on(self) -> bool: + """Check if the pump is currently on. + + Returns: + True if pump state is ON (1), False otherwise + """ + return self.state == PumpState.ON + + @property + def is_ready(self) -> bool: + """Check if the pump is ready to receive commands. + + A pump is considered ready if it's in a stable state (ON or OFF). + + Returns: + True if pump can accept commands, False otherwise + """ + return self.state in (PumpState.OFF, PumpState.ON) + + # Control methods + @dirties_state() + async def turn_on(self) -> None: + """Turn the pump on. + + This will turn on the pump at its last used speed setting. + """ + if self.bow_id is None or self.system_id is None: + msg = "Pump bow_id and system_id must be set" + raise ValueError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=True, + ) + + @dirties_state() + async def turn_off(self) -> None: + """Turn the pump off.""" + if self.bow_id is None or self.system_id is None: + msg = "Pump bow_id and system_id must be set" + raise ValueError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=False, + ) diff --git a/tests/test_chlorinator_bitmask.py b/tests/test_chlorinator_bitmask.py index caa9289..7bf4425 100644 --- a/tests/test_chlorinator_bitmask.py +++ b/tests/test_chlorinator_bitmask.py @@ -1,5 +1,7 @@ """Tests for chlorinator bitmask decoding.""" +# pylint: skip-file + from pyomnilogic_local.models.telemetry import TelemetryChlorinator diff --git a/tests/test_chlorinator_multibit.py b/tests/test_chlorinator_multibit.py index 2a2ae13..81d2453 100644 --- a/tests/test_chlorinator_multibit.py +++ b/tests/test_chlorinator_multibit.py @@ -1,5 +1,7 @@ """Tests for chlorinator multi-bit field special case handling.""" +# pylint: skip-file + from pyomnilogic_local.models.telemetry import TelemetryChlorinator diff --git a/tests/test_filter_pump.py b/tests/test_filter_pump.py new file mode 100644 index 0000000..30ae55e --- /dev/null +++ b/tests/test_filter_pump.py @@ -0,0 +1,349 @@ +"""Tests for Filter and Pump equipment classes.""" + +# pyright: basic +# mypy: ignore-errors +# type: ignore +# pylint: skip-file + +from unittest.mock import AsyncMock, Mock + +import pytest + +from pyomnilogic_local.filter import Filter +from pyomnilogic_local.models.mspconfig import MSPFilter, MSPPump +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryFilter, TelemetryPump +from pyomnilogic_local.omnitypes import ( + FilterSpeedPresets, + FilterState, + OmniType, + PumpState, +) +from pyomnilogic_local.pump import Pump + + +@pytest.fixture +def mock_omni(): + """Create a mock OmniLogic instance.""" + omni = Mock() + omni._api = Mock() + return omni + + +@pytest.fixture +def sample_filter_config(): + """Create a sample filter configuration.""" + return MSPFilter( + **{ + "System-Id": 8, + "Name": "Test Filter", + "Filter-Type": "FMT_VARIABLE_SPEED_PUMP", + "Max-Pump-Speed": 100, + "Min-Pump-Speed": 30, + "Max-Pump-RPM": 3450, + "Min-Pump-RPM": 1000, + "Priming-Enabled": "yes", + "Vsp-Low-Pump-Speed": 40, + "Vsp-Medium-Pump-Speed": 60, + "Vsp-High-Pump-Speed": 80, + } + ) + + +@pytest.fixture +def sample_filter_telemetry(): + """Create sample filter telemetry.""" + return TelemetryFilter( + omni_type=OmniType.FILTER, + **{ + "@systemId": 8, + "@filterState": 1, + "@filterSpeed": 60, + "@valvePosition": 1, + "@whyFilterIsOn": 14, + "@reportedFilterSpeed": 60, + "@power": 500, + "@lastSpeed": 50, + }, + ) + + +@pytest.fixture +def sample_pump_config(): + """Create a sample pump configuration.""" + return MSPPump( + **{ + "System-Id": 15, + "Name": "Test Pump", + "Type": "PMP_VARIABLE_SPEED_PUMP", + "Function": "PMP_PUMP", + "Max-Pump-Speed": 100, + "Min-Pump-Speed": 30, + "Max-Pump-RPM": 3450, + "Min-Pump-RPM": 1000, + "Priming-Enabled": "yes", + "Vsp-Low-Pump-Speed": 40, + "Vsp-Medium-Pump-Speed": 60, + "Vsp-High-Pump-Speed": 80, + } + ) + + +@pytest.fixture +def sample_pump_telemetry(): + """Create sample pump telemetry.""" + return TelemetryPump( + omni_type=OmniType.PUMP, + **{ + "@systemId": 15, + "@pumpState": 1, + "@pumpSpeed": 60, + "@lastSpeed": 50, + "@whyOn": 11, + }, + ) + + +@pytest.fixture +def mock_telemetry(sample_filter_telemetry, sample_pump_telemetry): + """Create a mock Telemetry object.""" + telemetry = Mock(spec=Telemetry) + telemetry.get_telem_by_systemid = Mock( + side_effect=lambda sid: sample_filter_telemetry if sid == 8 else sample_pump_telemetry if sid == 15 else None + ) + return telemetry + + +class TestFilter: + """Tests for Filter class.""" + + def test_filter_properties_config(self, mock_omni, sample_filter_config, mock_telemetry): + """Test that filter config properties are correctly exposed.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + + assert filter_obj.equip_type == "FMT_VARIABLE_SPEED_PUMP" + assert filter_obj.max_percent == 100 + assert filter_obj.min_percent == 30 + assert filter_obj.max_rpm == 3450 + assert filter_obj.min_rpm == 1000 + assert filter_obj.priming_enabled is True + assert filter_obj.low_speed == 40 + assert filter_obj.medium_speed == 60 + assert filter_obj.high_speed == 80 + + def test_filter_properties_telemetry(self, mock_omni, sample_filter_config, mock_telemetry): + """Test that filter telemetry properties are correctly exposed.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + + assert filter_obj.state == FilterState.ON + assert filter_obj.speed == 60 + assert filter_obj.valve_position == 1 + assert filter_obj.why_on == 14 + assert filter_obj.reported_speed == 60 + assert filter_obj.power == 500 + assert filter_obj.last_speed == 50 + + def test_filter_is_on_true(self, mock_omni, sample_filter_config, mock_telemetry): + """Test is_on returns True when filter is on.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + + assert filter_obj.is_on is True + + def test_filter_is_on_false(self, mock_omni, sample_filter_config, mock_telemetry): + """Test is_on returns False when filter is off.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj.telemetry.state = FilterState.OFF + + assert filter_obj.is_on is False + + def test_filter_is_ready_true(self, mock_omni, sample_filter_config, mock_telemetry): + """Test is_ready returns True for stable states.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + + # ON state + filter_obj.telemetry.state = FilterState.ON + assert filter_obj.is_ready is True + + # OFF state + filter_obj.telemetry.state = FilterState.OFF + assert filter_obj.is_ready is True + + def test_filter_is_ready_false(self, mock_omni, sample_filter_config, mock_telemetry): + """Test is_ready returns False for transitional states.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + + # PRIMING state + filter_obj.telemetry.state = FilterState.PRIMING + assert filter_obj.is_ready is False + + # WAITING_TURN_OFF state + filter_obj.telemetry.state = FilterState.WAITING_TURN_OFF + assert filter_obj.is_ready is False + + @pytest.mark.asyncio + async def test_filter_turn_on(self, mock_omni, sample_filter_config, mock_telemetry): + """Test turn_on method calls API correctly.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj._api.async_set_equipment = AsyncMock() + + await filter_obj.turn_on() + + filter_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=8, + is_on=True, + ) + + @pytest.mark.asyncio + async def test_filter_turn_off(self, mock_omni, sample_filter_config, mock_telemetry): + """Test turn_off method calls API correctly.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj._api.async_set_equipment = AsyncMock() + + await filter_obj.turn_off() + + filter_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=8, + is_on=False, + ) + + @pytest.mark.asyncio + async def test_filter_run_preset_speed_low(self, mock_omni, sample_filter_config, mock_telemetry): + """Test run_preset_speed with LOW preset.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj._api.async_set_filter_speed = AsyncMock() + + await filter_obj.run_preset_speed(FilterSpeedPresets.LOW) + + filter_obj._api.async_set_filter_speed.assert_called_once_with( + pool_id=7, + equipment_id=8, + speed=40, + ) + + @pytest.mark.asyncio + async def test_filter_run_preset_speed_medium(self, mock_omni, sample_filter_config, mock_telemetry): + """Test run_preset_speed with MEDIUM preset.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj._api.async_set_filter_speed = AsyncMock() + + await filter_obj.run_preset_speed(FilterSpeedPresets.MEDIUM) + + filter_obj._api.async_set_filter_speed.assert_called_once_with( + pool_id=7, + equipment_id=8, + speed=60, + ) + + @pytest.mark.asyncio + async def test_filter_run_preset_speed_high(self, mock_omni, sample_filter_config, mock_telemetry): + """Test run_preset_speed with HIGH preset.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj._api.async_set_filter_speed = AsyncMock() + + await filter_obj.run_preset_speed(FilterSpeedPresets.HIGH) + + filter_obj._api.async_set_filter_speed.assert_called_once_with( + pool_id=7, + equipment_id=8, + speed=80, + ) + + +class TestPump: + """Tests for Pump class.""" + + def test_pump_properties_config(self, mock_omni, sample_pump_config, mock_telemetry): + """Test that pump config properties are correctly exposed.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + + assert pump_obj.equip_type == "PMP_VARIABLE_SPEED_PUMP" + assert pump_obj.function == "PMP_PUMP" + assert pump_obj.max_percent == 100 + assert pump_obj.min_percent == 30 + assert pump_obj.max_rpm == 3450 + assert pump_obj.min_rpm == 1000 + assert pump_obj.priming_enabled is True + assert pump_obj.low_speed == 40 + assert pump_obj.medium_speed == 60 + assert pump_obj.high_speed == 80 + + def test_pump_properties_telemetry(self, mock_omni, sample_pump_config, mock_telemetry): + """Test that pump telemetry properties are correctly exposed.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + + assert pump_obj.state == PumpState.ON + assert pump_obj.speed == 60 + assert pump_obj.last_speed == 50 + assert pump_obj.why_on == 11 + + def test_pump_is_on_true(self, mock_omni, sample_pump_config, mock_telemetry): + """Test is_on returns True when pump is on.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + + assert pump_obj.is_on is True + + def test_pump_is_on_false(self, mock_omni, sample_pump_config, mock_telemetry): + """Test is_on returns False when pump is off.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + pump_obj.telemetry.state = PumpState.OFF + + assert pump_obj.is_on is False + + def test_pump_is_ready(self, mock_omni, sample_pump_config, mock_telemetry): + """Test is_ready returns True for stable states.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + + # ON state + pump_obj.telemetry.state = PumpState.ON + assert pump_obj.is_ready is True + + # OFF state + pump_obj.telemetry.state = PumpState.OFF + assert pump_obj.is_ready is True + + @pytest.mark.asyncio + async def test_pump_turn_on(self, mock_omni, sample_pump_config, mock_telemetry): + """Test turn_on method calls API correctly.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + pump_obj._api.async_set_equipment = AsyncMock() + + await pump_obj.turn_on() + + pump_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=15, + is_on=True, + ) + + @pytest.mark.asyncio + async def test_pump_turn_off(self, mock_omni, sample_pump_config, mock_telemetry): + """Test turn_off method calls API correctly.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + pump_obj._api.async_set_equipment = AsyncMock() + + await pump_obj.turn_off() + + pump_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=15, + is_on=False, + ) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index f1ba4cb..c78edd9 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,3 +1,5 @@ +# pylint: skip-file + import asyncio from unittest.mock import MagicMock, patch From b65480be47e54811bc351c8ae282c529803c0b83 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:17:33 -0500 Subject: [PATCH 11/61] fix: correct tests for filter/pump --- tests/test_filter_pump.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_filter_pump.py b/tests/test_filter_pump.py index 30ae55e..230dd25 100644 --- a/tests/test_filter_pump.py +++ b/tests/test_filter_pump.py @@ -197,7 +197,7 @@ async def test_filter_turn_on(self, mock_omni, sample_filter_config, mock_teleme filter_obj._api.async_set_equipment.assert_called_once_with( pool_id=7, equipment_id=8, - is_on=True, + is_on=filter_obj.last_speed, ) @pytest.mark.asyncio @@ -220,14 +220,14 @@ async def test_filter_run_preset_speed_low(self, mock_omni, sample_filter_config """Test run_preset_speed with LOW preset.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) - filter_obj._api.async_set_filter_speed = AsyncMock() + filter_obj._api.async_set_equipment = AsyncMock() await filter_obj.run_preset_speed(FilterSpeedPresets.LOW) - filter_obj._api.async_set_filter_speed.assert_called_once_with( + filter_obj._api.async_set_equipment.assert_called_once_with( pool_id=7, equipment_id=8, - speed=40, + is_on=40, ) @pytest.mark.asyncio @@ -235,14 +235,14 @@ async def test_filter_run_preset_speed_medium(self, mock_omni, sample_filter_con """Test run_preset_speed with MEDIUM preset.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) - filter_obj._api.async_set_filter_speed = AsyncMock() + filter_obj._api.async_set_equipment = AsyncMock() await filter_obj.run_preset_speed(FilterSpeedPresets.MEDIUM) - filter_obj._api.async_set_filter_speed.assert_called_once_with( + filter_obj._api.async_set_equipment.assert_called_once_with( pool_id=7, equipment_id=8, - speed=60, + is_on=filter_obj.medium_speed, ) @pytest.mark.asyncio @@ -250,14 +250,14 @@ async def test_filter_run_preset_speed_high(self, mock_omni, sample_filter_confi """Test run_preset_speed with HIGH preset.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) - filter_obj._api.async_set_filter_speed = AsyncMock() + filter_obj._api.async_set_equipment = AsyncMock() await filter_obj.run_preset_speed(FilterSpeedPresets.HIGH) - filter_obj._api.async_set_filter_speed.assert_called_once_with( + filter_obj._api.async_set_equipment.assert_called_once_with( pool_id=7, equipment_id=8, - speed=80, + is_on=filter_obj.high_speed, ) From 01125a37a7dc6de6b7cbf3434c30daf30746c301 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:20:50 -0500 Subject: [PATCH 12/61] fix: use correct exceptions in filter/pump --- pyomnilogic_local/filter.py | 7 ++++--- pyomnilogic_local/pump.py | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py index 43fb5f8..b3046dd 100644 --- a/pyomnilogic_local/filter.py +++ b/pyomnilogic_local/filter.py @@ -3,6 +3,7 @@ from pyomnilogic_local.models.mspconfig import MSPFilter from pyomnilogic_local.models.telemetry import TelemetryFilter from pyomnilogic_local.omnitypes import FilterSpeedPresets, FilterState +from pyomnilogic_local.util import OmniEquipmentNotInitializedError class Filter(OmniEquipment[MSPFilter, TelemetryFilter]): @@ -128,7 +129,7 @@ async def turn_on(self) -> None: """ if self.bow_id is None or self.system_id is None: msg = "Filter bow_id and system_id must be set" - raise ValueError(msg) + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_equipment( pool_id=self.bow_id, @@ -141,7 +142,7 @@ async def turn_off(self) -> None: """Turn the filter off.""" if self.bow_id is None or self.system_id is None: msg = "Filter bow_id and system_id must be set" - raise ValueError(msg) + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_equipment( pool_id=self.bow_id, @@ -158,7 +159,7 @@ async def run_preset_speed(self, speed: FilterSpeedPresets) -> None: """ if self.bow_id is None or self.system_id is None: msg = "Filter bow_id and system_id must be set" - raise ValueError(msg) + raise OmniEquipmentNotInitializedError(msg) speed_value: int match speed: diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index cc64696..ba52f78 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -3,6 +3,7 @@ from pyomnilogic_local.models.mspconfig import MSPPump from pyomnilogic_local.models.telemetry import TelemetryPump from pyomnilogic_local.omnitypes import PumpState +from pyomnilogic_local.util import OmniEquipmentNotInitializedError class Pump(OmniEquipment[MSPPump, TelemetryPump]): @@ -110,7 +111,7 @@ async def turn_on(self) -> None: """ if self.bow_id is None or self.system_id is None: msg = "Pump bow_id and system_id must be set" - raise ValueError(msg) + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_equipment( pool_id=self.bow_id, @@ -123,7 +124,7 @@ async def turn_off(self) -> None: """Turn the pump off.""" if self.bow_id is None or self.system_id is None: msg = "Pump bow_id and system_id must be set" - raise ValueError(msg) + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_equipment( pool_id=self.bow_id, From a64aab4e3cc69037feb2e64c2ccf44a8efbe4c52 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:57:55 -0500 Subject: [PATCH 13/61] feat: add is_ready attribute to lights --- pyomnilogic_local/colorlogiclight.py | 76 ++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index 44a4633..4f1dd9e 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -12,7 +12,10 @@ ColorLogicSpeed, LightShows, ) -from pyomnilogic_local.util import OmniEquipmentNotInitializedError +from pyomnilogic_local.util import ( + OmniEquipmentNotInitializedError, + OmniEquipmentNotReadyError, +) if TYPE_CHECKING: from pyomnilogic_local.omnilogic import OmniLogic @@ -72,25 +75,85 @@ def special_effect(self) -> int: """Returns the current special effect.""" return self.telemetry.special_effect + @property + def is_ready(self) -> bool: + """ + Returns whether the light is ready to accept commands. + + The light is not ready when it is in a transitional state: + - FIFTEEN_SECONDS_WHITE: Light is in the 15-second white period after power on + - CHANGING_SHOW: Light is actively changing between shows + - POWERING_OFF: Light is in the process of turning off + - COOLDOWN: Light is in cooldown period after being turned off + + Returns: + bool: True if the light can accept commands, False otherwise. + """ + return self.state not in [ + ColorLogicPowerState.FIFTEEN_SECONDS_WHITE, + ColorLogicPowerState.CHANGING_SHOW, + ColorLogicPowerState.POWERING_OFF, + ColorLogicPowerState.COOLDOWN, + ] + @dirties_state() async def turn_on(self) -> None: - """Turns the light on.""" + """ + Turns the light on. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the light is not ready to accept commands + (in FIFTEEN_SECONDS_WHITE, CHANGING_SHOW, POWERING_OFF, or COOLDOWN state). + """ if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot turn on light: bow_id or system_id is None") + if not self.is_ready: + raise OmniEquipmentNotReadyError( + f"Cannot turn on light: light is in {self.state.pretty()} state. Wait for the light to be ready before issuing commands." + ) await self._api.async_set_equipment(self.bow_id, self.system_id, True) @dirties_state() async def turn_off(self) -> None: - """Turns the light off.""" + """ + Turns the light off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the light is not ready to accept commands + (in FIFTEEN_SECONDS_WHITE, CHANGING_SHOW, POWERING_OFF, or COOLDOWN state). + """ if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot turn off light: bow_id or system_id is None") + if not self.is_ready: + raise OmniEquipmentNotReadyError( + f"Cannot turn off light: light is in {self.state.pretty()} state. Wait for the light to be ready before issuing commands." + ) await self._api.async_set_equipment(self.bow_id, self.system_id, False) @dirties_state() async def set_show( self, show: LightShows | None = None, speed: ColorLogicSpeed | None = None, brightness: ColorLogicBrightness | None = None ) -> None: - """Sets the light show, speed, and brightness.""" + """ + Sets the light show, speed, and brightness. + + Args: + show: The light show to set. If None, uses the current show. + speed: The speed to set. If None, uses the current speed. + brightness: The brightness to set. If None, uses the current brightness. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the light is not ready to accept commands + (in FIFTEEN_SECONDS_WHITE, CHANGING_SHOW, POWERING_OFF, or COOLDOWN state). + + Note: + Non color-logic lights do not support speed or brightness control. + If speed or brightness are provided for non color-logic lights, they will be ignored + and a warning will be logged. + """ # Non color-logic lights do not support speed or brightness control if self.model not in [ @@ -109,6 +172,11 @@ async def set_show( if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot set light show: bow_id or system_id is None") + if not self.is_ready: + raise OmniEquipmentNotReadyError( + f"Cannot set light show: light is in {self.state.pretty()} state. Wait for the light to be ready before issuing commands." + ) + await self._api.async_set_light_show( self.bow_id, self.system_id, From b8907e0dfc6f55880980b5e0a4187e43343f61bc Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 21:25:22 -0500 Subject: [PATCH 14/61] feat: implement relays --- pyomnilogic_local/bow.py | 59 ++++++++++++------------- pyomnilogic_local/models/mspconfig.py | 4 +- pyomnilogic_local/models/telemetry.py | 8 ++-- pyomnilogic_local/omnitypes.py | 2 +- pyomnilogic_local/relay.py | 62 ++++++++++++++++++++++++++- 5 files changed, 98 insertions(+), 37 deletions(-) diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index d1a41ec..b3ff328 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -42,13 +42,30 @@ def _update_equipment(self, mspconfig: MSPBoW, telemetry: Telemetry | None) -> N if telemetry is None: _LOGGER.warning("No telemetry provided to update Bow equipment.") return + self._update_chlorinators(mspconfig, telemetry) + self._update_csads(mspconfig, telemetry) self._update_filters(mspconfig, telemetry) self._update_heater(mspconfig, telemetry) - self._update_sensors(mspconfig, telemetry) self._update_lights(mspconfig, telemetry) self._update_pumps(mspconfig, telemetry) - self._update_chlorinators(mspconfig, telemetry) - self._update_csads(mspconfig, telemetry) + self._update_relays(mspconfig, telemetry) + self._update_sensors(mspconfig, telemetry) + + def _update_chlorinators(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the chlorinators based on the MSP configuration.""" + if mspconfig.chlorinator is None: + self.chlorinator = None + return + + self.chlorinator = Chlorinator(self._omni, mspconfig.chlorinator, telemetry) + + def _update_csads(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the CSADs based on the MSP configuration.""" + if mspconfig.csad is None: + self.csads = EquipmentDict() + return + + self.csads = EquipmentDict([CSAD(self._omni, csad, telemetry) for csad in mspconfig.csad]) def _update_filters(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the filters based on the MSP configuration.""" @@ -66,22 +83,6 @@ def _update_heater(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.heater = Heater(self._omni, mspconfig.heater, telemetry) - def _update_relays(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: - """Update the relays based on the MSP configuration.""" - if mspconfig.relay is None: - self.relays = EquipmentDict() - return - - self.relays = EquipmentDict([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.""" - if mspconfig.sensor is None: - self.sensors = EquipmentDict() - return - - self.sensors = EquipmentDict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor]) - def _update_lights(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: """Update the lights based on the MSP configuration.""" if mspconfig.colorlogic_light is None: @@ -98,18 +99,18 @@ def _update_pumps(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: self.pumps = EquipmentDict([Pump(self._omni, pump, telemetry) for pump in mspconfig.pump]) - def _update_chlorinators(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: - """Update the chlorinators based on the MSP configuration.""" - if mspconfig.chlorinator is None: - self.chlorinator = None + def _update_relays(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the relays based on the MSP configuration.""" + if mspconfig.relay is None: + self.relays = EquipmentDict() return - self.chlorinator = Chlorinator(self._omni, mspconfig.chlorinator, telemetry) + self.relays = EquipmentDict([Relay(self._omni, relay, telemetry) for relay in mspconfig.relay]) - def _update_csads(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: - """Update the CSADs based on the MSP configuration.""" - if mspconfig.csad is None: - self.csads = EquipmentDict() + def _update_sensors(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the sensors based on the MSP configuration.""" + if mspconfig.sensor is None: + self.sensors = EquipmentDict() return - self.csads = EquipmentDict([CSAD(self._omni, csad, telemetry) for csad in mspconfig.csad]) + self.sensors = EquipmentDict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor]) diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 00e8532..2749d8c 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -157,8 +157,8 @@ class MSPPump(OmniBase): class MSPRelay(OmniBase): omni_type: OmniType = OmniType.RELAY - type: RelayType | str = Field(alias="Type") - function: RelayFunction | str = Field(alias="Function") + type: RelayType = Field(alias="Type") + function: RelayFunction = Field(alias="Function") class MSPHeaterEquip(OmniBase): diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index d8b6bc9..cfec5e5 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -275,8 +275,8 @@ class TelemetryRelay(BaseModel): omni_type: OmniType = OmniType.RELAY system_id: int = Field(alias="@systemId") - state: RelayState | int = Field(alias="@relayState") - why_on: RelayWhyOn | int = Field(alias="@whyOn") + state: RelayState = Field(alias="@relayState") + why_on: RelayWhyOn = Field(alias="@whyOn") class TelemetryValveActuator(BaseModel): @@ -284,9 +284,9 @@ class TelemetryValveActuator(BaseModel): omni_type: OmniType = OmniType.VALVE_ACTUATOR system_id: int = Field(alias="@systemId") - state: ValveActuatorState | int = Field(alias="@valveActuatorState") + state: ValveActuatorState = Field(alias="@valveActuatorState") # Valve actuators are actually relays, so we can reuse the RelayWhyOn enum here - why_on: RelayWhyOn | int = Field(alias="@whyOn") + why_on: RelayWhyOn = Field(alias="@whyOn") class TelemetryVirtualHeater(BaseModel): diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index d4fd8fb..bc13c59 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -494,7 +494,7 @@ class RelayFunction(StrEnum, PrettyEnum): CLEANER_IN_FLOOR = "RLY_CLEANER_IN_FLOOR" -class RelayState(IntEnum, PrettyEnum): +class RelayState(PrettyEnum): OFF = 0 ON = 1 diff --git a/pyomnilogic_local/relay.py b/pyomnilogic_local/relay.py index 02d2990..9e3df44 100644 --- a/pyomnilogic_local/relay.py +++ b/pyomnilogic_local/relay.py @@ -1,7 +1,67 @@ +from typing import TYPE_CHECKING + from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPRelay -from pyomnilogic_local.models.telemetry import TelemetryRelay +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryRelay +from pyomnilogic_local.omnitypes import RelayFunction, RelayState, RelayType, RelayWhyOn +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic class Relay(OmniEquipment[MSPRelay, TelemetryRelay]): """Represents a relay in the OmniLogic system.""" + + def __init__(self, omni: "OmniLogic", mspconfig: MSPRelay, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def relay_type(self) -> RelayType: + """Returns the type of the relay.""" + return self.mspconfig.type + + @property + def function(self) -> RelayFunction: + """Returns the function of the relay.""" + return self.mspconfig.function + + @property + def state(self) -> RelayState: + """Returns the current state of the relay.""" + return self.telemetry.state + + @property + def why_on(self) -> RelayWhyOn: + """Returns the reason why the relay is on.""" + return self.telemetry.why_on + + @property + def is_on(self) -> bool: + """Returns whether the relay is currently on.""" + return self.state == RelayState.ON + + @dirties_state() + async def turn_on(self) -> None: + """ + Turns the relay on. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn on relay: bow_id or system_id is None") + await self._api.async_set_equipment(self.bow_id, self.system_id, True) + + @dirties_state() + async def turn_off(self) -> None: + """ + Turns the relay off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn off relay: bow_id or system_id is None") + await self._api.async_set_equipment(self.bow_id, self.system_id, False) From de2b010797a2acb2a35c7b8266be126d499dec5b Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:04:25 -0500 Subject: [PATCH 15/61] feat: implement heater control --- pyomnilogic_local/api/api.py | 8 +- pyomnilogic_local/heater.py | 140 ++++++++++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 7 deletions(-) diff --git a/pyomnilogic_local/api/api.py b/pyomnilogic_local/api/api.py index d754e1c..a74fa61 100644 --- a/pyomnilogic_local/api/api.py +++ b/pyomnilogic_local/api/api.py @@ -156,15 +156,13 @@ async def async_set_heater( pool_id: int, equipment_id: int, temperature: int, - unit: str, ) -> None: """Set the temperature for a heater on the Omni Args: pool_id (int): The Pool/BodyOfWater ID that you want to address equipment_id (int): Which equipment_id within that Pool to address - temperature (int): What temperature to request - unit (str): The temperature unit to use (either F or C) + temperature (int): What temperature to request (must be in Fahrenheit) Returns: None @@ -179,11 +177,13 @@ async def async_set_heater( parameter.text = str(pool_id) parameter = ET.SubElement(parameters_element, "Parameter", name="HeaterID", dataType="int", alias="EquipmentID") parameter.text = str(equipment_id) - parameter = ET.SubElement(parameters_element, "Parameter", name="Temp", dataType="int", unit=unit, alias="Data") + parameter = ET.SubElement(parameters_element, "Parameter", name="Temp", dataType="int", unit="F", alias="Data") parameter.text = str(temperature) req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + print(req_body) + return await self.async_send_message(MessageType.SET_HEATER_COMMAND, req_body, False) async def async_set_solar_heater( diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py index 64fc2f7..0cb0047 100644 --- a/pyomnilogic_local/heater.py +++ b/pyomnilogic_local/heater.py @@ -1,7 +1,141 @@ +from typing import TYPE_CHECKING + from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPVirtualHeater -from pyomnilogic_local.models.telemetry import TelemetryHeater +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryVirtualHeater +from pyomnilogic_local.omnitypes import HeaterMode +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + + +class Heater(OmniEquipment[MSPVirtualHeater, TelemetryVirtualHeater]): + """ + Represents a heater in the OmniLogic system. + + Note: Temperature is always in Fahrenheit internally, so all temperature + properties and methods use Fahrenheit. Use the omni.system.units property to + determine if conversion to Celsius should be performed for display. + """ + + def __init__(self, omni: "OmniLogic", mspconfig: MSPVirtualHeater, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def max_temp(self) -> int: + """ + Returns the maximum settable temperature. + + Note: Temperature is always in Fahrenheit internally. + Use the system.units property to determine if conversion to Celsius is needed for display. + """ + return self.mspconfig.max_temp + + @property + def min_temp(self) -> int: + """ + Returns the minimum settable temperature. + + Note: Temperature is always in Fahrenheit internally. + Use the system.units property to determine if conversion to Celsius is needed for display. + """ + return self.mspconfig.min_temp + + @property + def mode(self) -> HeaterMode | int: + """Returns the current heater mode from telemetry.""" + return self.telemetry.mode + + @property + def current_set_point(self) -> int: + """ + Returns the current set point from telemetry. + + Note: Temperature is always in Fahrenheit internally. + Use the system.units property to determine if conversion to Celsius is needed for display. + """ + return self.telemetry.current_set_point + + @property + def solar_set_point(self) -> int: + """ + Returns the solar set point from telemetry. + + Note: Temperature is always in Fahrenheit internally. + Use the system.units property to determine if conversion to Celsius is needed for display. + """ + return self.telemetry.solar_set_point + + @property + def enabled(self) -> bool: + """Returns whether the heater is enabled from telemetry.""" + return self.telemetry.enabled + + @property + def silent_mode(self) -> int: + """Returns the silent mode setting from telemetry.""" + return self.telemetry.silent_mode + + @property + def why_on(self) -> int: + """Returns the reason why the heater is on from telemetry.""" + return self.telemetry.why_on + + @property + def is_on(self) -> bool: + """Returns whether the heater is currently enabled (from telemetry).""" + return self.telemetry.enabled + + @dirties_state() + async def turn_on(self) -> None: + """ + Turns the heater on (enables it). + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn on heater: bow_id or system_id is None") + await self._api.async_set_heater_enable(self.bow_id, self.system_id, True) + + @dirties_state() + async def turn_off(self) -> None: + """ + Turns the heater off (disables it). + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn off heater: bow_id or system_id is None") + await self._api.async_set_heater_enable(self.bow_id, self.system_id, False) + + @dirties_state() + async def set_temperature(self, temperature: int) -> None: + """ + Sets the target temperature for the heater. + + Args: + temperature: The target temperature to set in Fahrenheit. + Must be between min_temp and max_temp. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If temperature is outside the valid range. + + Note: + Temperature must be provided in Fahrenheit as that is what the OmniLogic + system uses internally. The system.units setting only affects display, + not the API. If your application uses Celsius, you must convert to + Fahrenheit before calling this method. + """ + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot set heater temperature: bow_id or system_id is None") + if temperature < self.min_temp or temperature > self.max_temp: + raise ValueError(f"Temperature {temperature}°F is outside valid range [{self.min_temp}°F, {self.max_temp}°F]") -class Heater(OmniEquipment[MSPVirtualHeater, TelemetryHeater]): - """Represents a heater in the OmniLogic system.""" + # Always use Fahrenheit as that's what the OmniLogic system uses internally + await self._api.async_set_heater(self.bow_id, self.system_id, temperature) From 5d0e005817f3570310ad0c2da1249d85bc98acd0 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:33:06 -0500 Subject: [PATCH 16/61] feat: implement heater equipment under the virtual heater --- pyomnilogic_local/api/api.py | 6 +- pyomnilogic_local/heater.py | 46 ++++++++++++++++ pyomnilogic_local/heater_equip.py | 79 +++++++++++++++++++++++++++ pyomnilogic_local/models/mspconfig.py | 2 +- pyomnilogic_local/models/telemetry.py | 4 +- 5 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 pyomnilogic_local/heater_equip.py diff --git a/pyomnilogic_local/api/api.py b/pyomnilogic_local/api/api.py index a74fa61..fd7c51d 100644 --- a/pyomnilogic_local/api/api.py +++ b/pyomnilogic_local/api/api.py @@ -191,15 +191,13 @@ async def async_set_solar_heater( pool_id: int, equipment_id: int, temperature: int, - unit: str, ) -> None: """Set the solar set point for a heater on the Omni. Args: pool_id (int): The Pool/BodyOfWater ID that you want to address equipment_id (int): Which equipment_id within that Pool to address - temperature (int): What temperature to request - unit (str): The temperature unit to use (either F or C) + temperature (int): What temperature to request (must be in Fahrenheit) Returns: None @@ -214,7 +212,7 @@ async def async_set_solar_heater( parameter.text = str(pool_id) parameter = ET.SubElement(parameters_element, "Parameter", name="HeaterID", dataType="int", alias="EquipmentID") parameter.text = str(equipment_id) - parameter = ET.SubElement(parameters_element, "Parameter", name="Temp", dataType="int", unit=unit, alias="Data") + parameter = ET.SubElement(parameters_element, "Parameter", name="Temp", dataType="int", unit="F", alias="Data") parameter.text = str(temperature) req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py index 0cb0047..f4dc548 100644 --- a/pyomnilogic_local/heater.py +++ b/pyomnilogic_local/heater.py @@ -1,7 +1,9 @@ from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.heater_equip import HeaterEquipment from pyomnilogic_local.models.mspconfig import MSPVirtualHeater from pyomnilogic_local.models.telemetry import Telemetry, TelemetryVirtualHeater from pyomnilogic_local.omnitypes import HeaterMode @@ -20,9 +22,25 @@ class Heater(OmniEquipment[MSPVirtualHeater, TelemetryVirtualHeater]): determine if conversion to Celsius should be performed for display. """ + heater_equipment: EquipmentDict[HeaterEquipment] = EquipmentDict() + def __init__(self, omni: "OmniLogic", mspconfig: MSPVirtualHeater, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) + def _update_equipment(self, mspconfig: MSPVirtualHeater, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + if telemetry is None: + return + self._update_heater_equipment(mspconfig, telemetry) + + def _update_heater_equipment(self, mspconfig: MSPVirtualHeater, telemetry: Telemetry) -> None: + """Update the heater equipment based on the MSP configuration.""" + if mspconfig.heater_equipment is None: + self.heater_equipment = EquipmentDict() + return + + self.heater_equipment = EquipmentDict([HeaterEquipment(self._omni, equip, telemetry) for equip in mspconfig.heater_equipment]) + @property def max_temp(self) -> int: """ @@ -139,3 +157,31 @@ async def set_temperature(self, temperature: int) -> None: # Always use Fahrenheit as that's what the OmniLogic system uses internally await self._api.async_set_heater(self.bow_id, self.system_id, temperature) + + @dirties_state() + async def set_solar_temperature(self, temperature: int) -> None: + """ + Sets the solar heater set point. + + Args: + temperature: The target solar temperature to set in Fahrenheit. + Must be between min_temp and max_temp. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If temperature is outside the valid range. + + Note: + Temperature must be provided in Fahrenheit as that is what the OmniLogic + system uses internally. The system.units setting only affects display, + not the API. If your application uses Celsius, you must convert to + Fahrenheit before calling this method. + """ + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot set solar heater temperature: bow_id or system_id is None") + + if temperature < self.min_temp or temperature > self.max_temp: + raise ValueError(f"Temperature {temperature}°F is outside valid range [{self.min_temp}°F, {self.max_temp}°F]") + + # Always use Fahrenheit as that's what the OmniLogic system uses internally + await self._api.async_set_solar_heater(self.bow_id, self.system_id, temperature) diff --git a/pyomnilogic_local/heater_equip.py b/pyomnilogic_local/heater_equip.py new file mode 100644 index 0000000..03866db --- /dev/null +++ b/pyomnilogic_local/heater_equip.py @@ -0,0 +1,79 @@ +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPHeaterEquip +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryHeater +from pyomnilogic_local.omnitypes import HeaterState, HeaterType + +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + + +class HeaterEquipment(OmniEquipment[MSPHeaterEquip, TelemetryHeater]): + """ + Represents a heater equipment in the OmniLogic system. + + This is the physical heater equipment (gas, heat pump, solar, etc.) that is + controlled by a VirtualHeater. A VirtualHeater can have one or more HeaterEquipment + instances associated with it. + + Note: Temperature is always in Fahrenheit internally. + """ + + def __init__(self, omni: "OmniLogic", mspconfig: MSPHeaterEquip, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def heater_type(self) -> HeaterType | str: + """Returns the type of heater (GAS, HEAT_PUMP, SOLAR, etc.).""" + return self.mspconfig.heater_type + + @property + def min_filter_speed(self) -> int: + """Returns the minimum filter speed required for heater operation.""" + return self.mspconfig.min_filter_speed + + @property + def sensor_id(self) -> int: + """Returns the system ID of the sensor associated with this heater.""" + return self.mspconfig.sensor_id + + @property + def supports_cooling(self) -> bool | None: + """Returns whether the heater supports cooling mode, if available.""" + return self.mspconfig.supports_cooling + + @property + def state(self) -> HeaterState | int: + """Returns the current state of the heater equipment (OFF, ON, or PAUSE).""" + return self.telemetry.state + + @property + def current_temp(self) -> int: + """ + Returns the current temperature reading from telemetry. + + Note: Temperature is always in Fahrenheit internally. + Use the system.units property to determine if conversion to Celsius is needed for display. + """ + return self.telemetry.temp + + @property + def enabled(self) -> bool: + """Returns whether the heater equipment is enabled from telemetry.""" + return self.telemetry.enabled + + @property + def priority(self) -> int: + """Returns the priority of this heater equipment.""" + return self.telemetry.priority + + @property + def maintain_for(self) -> int: + """Returns the maintain_for value from telemetry.""" + return self.telemetry.maintain_for + + @property + def is_on(self) -> bool: + """Returns whether the heater equipment is currently on.""" + return self.state == HeaterState.ON diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 2749d8c..791d9e6 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -167,7 +167,7 @@ class MSPHeaterEquip(OmniBase): omni_type: OmniType = OmniType.HEATER_EQUIP equip_type: Literal["PET_HEATER"] = Field(alias="Type") - heater_type: HeaterType | str = Field(alias="Heater-Type") + heater_type: HeaterType = Field(alias="Heater-Type") enabled: bool = Field(alias="Enabled") min_filter_speed: int = Field(alias="Min-Speed-For-Operation") sensor_id: int = Field(alias="Sensor-System-Id") diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index cfec5e5..08ee47a 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -252,7 +252,7 @@ class TelemetryHeater(BaseModel): omni_type: OmniType = OmniType.HEATER system_id: int = Field(alias="@systemId") - state: HeaterState | int = Field(alias="@heaterState") + state: HeaterState = Field(alias="@heaterState") temp: int = Field(alias="@temp") enabled: bool = Field(alias="@enable") priority: int = Field(alias="@priority") @@ -297,7 +297,7 @@ class TelemetryVirtualHeater(BaseModel): current_set_point: int = Field(alias="@Current-Set-Point") enabled: bool = Field(alias="@enable") solar_set_point: int = Field(alias="@SolarSetPoint") - mode: HeaterMode | int = Field(alias="@Mode") + mode: HeaterMode = Field(alias="@Mode") silent_mode: int = Field(alias="@SilentMode") why_on: int = Field(alias="@whyHeaterIsOn") From e4ef473804e54eafcfb12e728f2d42a6c863deb1 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:44:47 -0500 Subject: [PATCH 17/61] feat: implement sensors - Also added mspconfig and telemetry type hints to all equipment classes --- pyomnilogic_local/backyard.py | 2 ++ pyomnilogic_local/bow.py | 2 ++ pyomnilogic_local/chlorinator.py | 3 +++ pyomnilogic_local/colorlogiclight.py | 3 +++ pyomnilogic_local/csad.py | 3 +++ pyomnilogic_local/filter.py | 3 +++ pyomnilogic_local/heater.py | 2 ++ pyomnilogic_local/heater_equip.py | 5 +++- pyomnilogic_local/pump.py | 3 +++ pyomnilogic_local/relay.py | 3 +++ pyomnilogic_local/sensor.py | 34 +++++++++++++++++++++++++++- pyomnilogic_local/system.py | 2 ++ 12 files changed, 63 insertions(+), 2 deletions(-) diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py index 53179b2..3546358 100644 --- a/pyomnilogic_local/backyard.py +++ b/pyomnilogic_local/backyard.py @@ -20,6 +20,8 @@ class Backyard(OmniEquipment[MSPBackyard, TelemetryBackyard]): """Represents the backyard equipment in the OmniLogic system.""" + mspconfig: MSPBackyard + telemetry: TelemetryBackyard bow: EquipmentDict[Bow] = EquipmentDict() lights: EquipmentDict[ColorLogicLight] = EquipmentDict() relays: EquipmentDict[Relay] = EquipmentDict() diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index b3ff328..c211d6f 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -20,6 +20,8 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): """Represents a bow in the OmniLogic system.""" + mspconfig: MSPBoW + telemetry: TelemetryBoW filters: EquipmentDict[Filter] = EquipmentDict() heater: Heater | None = None relays: EquipmentDict[Relay] = EquipmentDict() diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py index 0218187..1692e70 100644 --- a/pyomnilogic_local/chlorinator.py +++ b/pyomnilogic_local/chlorinator.py @@ -5,3 +5,6 @@ class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): """Represents a chlorinator in the OmniLogic system.""" + + mspconfig: MSPChlorinator + telemetry: TelemetryChlorinator diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index 4f1dd9e..dfe9d24 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -26,6 +26,9 @@ class ColorLogicLight(OmniEquipment[MSPColorLogicLight, TelemetryColorLogicLight]): """Represents a color logic light.""" + mspconfig: MSPColorLogicLight + telemetry: TelemetryColorLogicLight + def __init__(self, omni: "OmniLogic", mspconfig: MSPColorLogicLight, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) diff --git a/pyomnilogic_local/csad.py b/pyomnilogic_local/csad.py index e827618..f362fca 100644 --- a/pyomnilogic_local/csad.py +++ b/pyomnilogic_local/csad.py @@ -5,3 +5,6 @@ class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]): """Represents a CSAD in the OmniLogic system.""" + + mspconfig: MSPCSAD + telemetry: TelemetryCSAD diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py index b3046dd..9bf3395 100644 --- a/pyomnilogic_local/filter.py +++ b/pyomnilogic_local/filter.py @@ -9,6 +9,9 @@ class Filter(OmniEquipment[MSPFilter, TelemetryFilter]): """Represents a filter in the OmniLogic system.""" + mspconfig: MSPFilter + telemetry: TelemetryFilter + # Expose MSPConfig attributes @property def equip_type(self) -> str: diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py index f4dc548..900e98b 100644 --- a/pyomnilogic_local/heater.py +++ b/pyomnilogic_local/heater.py @@ -22,6 +22,8 @@ class Heater(OmniEquipment[MSPVirtualHeater, TelemetryVirtualHeater]): determine if conversion to Celsius should be performed for display. """ + mspconfig: MSPVirtualHeater + telemetry: TelemetryVirtualHeater heater_equipment: EquipmentDict[HeaterEquipment] = EquipmentDict() def __init__(self, omni: "OmniLogic", mspconfig: MSPVirtualHeater, telemetry: Telemetry) -> None: diff --git a/pyomnilogic_local/heater_equip.py b/pyomnilogic_local/heater_equip.py index 03866db..66bca3e 100644 --- a/pyomnilogic_local/heater_equip.py +++ b/pyomnilogic_local/heater_equip.py @@ -20,11 +20,14 @@ class HeaterEquipment(OmniEquipment[MSPHeaterEquip, TelemetryHeater]): Note: Temperature is always in Fahrenheit internally. """ + mspconfig: MSPHeaterEquip + telemetry: TelemetryHeater + def __init__(self, omni: "OmniLogic", mspconfig: MSPHeaterEquip, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @property - def heater_type(self) -> HeaterType | str: + def heater_type(self) -> HeaterType: """Returns the type of heater (GAS, HEAT_PUMP, SOLAR, etc.).""" return self.mspconfig.heater_type diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index ba52f78..53118d6 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -9,6 +9,9 @@ class Pump(OmniEquipment[MSPPump, TelemetryPump]): """Represents a pump in the OmniLogic system.""" + mspconfig: MSPPump + telemetry: TelemetryPump + # Expose MSPConfig attributes @property def equip_type(self) -> str: diff --git a/pyomnilogic_local/relay.py b/pyomnilogic_local/relay.py index 9e3df44..c769b98 100644 --- a/pyomnilogic_local/relay.py +++ b/pyomnilogic_local/relay.py @@ -14,6 +14,9 @@ class Relay(OmniEquipment[MSPRelay, TelemetryRelay]): """Represents a relay in the OmniLogic system.""" + mspconfig: MSPRelay + telemetry: TelemetryRelay + def __init__(self, omni: "OmniLogic", mspconfig: MSPRelay, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) diff --git a/pyomnilogic_local/sensor.py b/pyomnilogic_local/sensor.py index 6bfcf06..eaa9325 100644 --- a/pyomnilogic_local/sensor.py +++ b/pyomnilogic_local/sensor.py @@ -1,10 +1,42 @@ +from typing import TYPE_CHECKING + from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.models.mspconfig import MSPSensor +from pyomnilogic_local.models.telemetry import Telemetry +from pyomnilogic_local.omnitypes import SensorType, SensorUnits + +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic class Sensor(OmniEquipment[MSPSensor, None]): - """Represents a sensor in the OmniLogic system. + """ + Represents a sensor in the OmniLogic system. Note: Sensors don't have their own telemetry - they contribute data to other equipment (like BoW, Backyard, Heaters, etc.) """ + + mspconfig: MSPSensor + + def __init__(self, omni: "OmniLogic", mspconfig: MSPSensor, telemetry: Telemetry | None) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def sensor_type(self) -> SensorType | str: + """ + Returns the type of sensor. + + Can be AIR_TEMP, SOLAR_TEMP, WATER_TEMP, FLOW, ORP, or EXT_INPUT. + """ + return self.mspconfig.equip_type + + @property + def units(self) -> SensorUnits | str: + """ + Returns the units used by the sensor. + + Can be FAHRENHEIT, CELSIUS, PPM, GRAMS_PER_LITER, MILLIVOLTS, + NO_UNITS, or ACTIVE_INACTIVE. + """ + return self.mspconfig.units diff --git a/pyomnilogic_local/system.py b/pyomnilogic_local/system.py index 7ef3533..d569aa7 100644 --- a/pyomnilogic_local/system.py +++ b/pyomnilogic_local/system.py @@ -4,6 +4,8 @@ class System: """Represents the main system equipment in the OmniLogic system.""" + mspconfig: MSPSystem + def __init__(self, mspconfig: MSPSystem) -> None: # self.vsp_speed_format = mspconfig.vsp_speed_format # self.units = mspconfig.units From 818f88f524cff2241a6b1e30e2456c63718b5bb5 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 30 Oct 2025 23:09:59 -0500 Subject: [PATCH 18/61] feat: add equipment discovery properties and search methods to OmniLogic - @property methods for accessing equipment by type: all_lights, all_filters, etc - Add get_equipment_by_name() and get_equipment_by_id() for searching across all equipment. --- pyomnilogic_local/api/api.py | 2 - pyomnilogic_local/omnilogic.py | 153 ++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/pyomnilogic_local/api/api.py b/pyomnilogic_local/api/api.py index fd7c51d..e7ac6fb 100644 --- a/pyomnilogic_local/api/api.py +++ b/pyomnilogic_local/api/api.py @@ -182,8 +182,6 @@ async def async_set_heater( req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") - print(req_body) - return await self.async_send_message(MessageType.SET_HEATER_COMMAND, req_body, False) async def async_set_solar_heater( diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index 3d9a968..3e51d04 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -1,10 +1,22 @@ import asyncio import logging import time +from typing import Any +from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.api import OmniLogicAPI from pyomnilogic_local.backyard import Backyard +from pyomnilogic_local.chlorinator import Chlorinator +from pyomnilogic_local.collections import EquipmentDict +from pyomnilogic_local.colorlogiclight import ColorLogicLight +from pyomnilogic_local.csad import CSAD +from pyomnilogic_local.filter import Filter +from pyomnilogic_local.heater import Heater +from pyomnilogic_local.heater_equip import HeaterEquipment from pyomnilogic_local.models import MSPConfig, Telemetry +from pyomnilogic_local.pump import Pump +from pyomnilogic_local.relay import Relay +from pyomnilogic_local.sensor import Sensor from pyomnilogic_local.system import System _LOGGER = logging.getLogger(__name__) @@ -102,4 +114,143 @@ def _update_equipment(self) -> None: except AttributeError: self.backyard = Backyard(self, self.mspconfig.backyard, self.telemetry) - # No need for _set_omni_reference anymore - it's passed in __init__! + # Equipment discovery properties + @property + def all_lights(self) -> EquipmentDict[ColorLogicLight]: + """Returns all ColorLogicLight instances across all bows in the backyard.""" + lights: list[ColorLogicLight] = [] + # Lights at backyard level + lights.extend(self.backyard.lights.values()) + # Lights in each bow + for bow in self.backyard.bow.values(): + lights.extend(bow.lights.values()) + return EquipmentDict(lights) + + @property + def all_relays(self) -> EquipmentDict[Relay]: + """Returns all Relay instances across all bows in the backyard.""" + relays: list[Relay] = [] + # Relays at backyard level + relays.extend(self.backyard.relays.values()) + # Relays in each bow + for bow in self.backyard.bow.values(): + relays.extend(bow.relays.values()) + return EquipmentDict(relays) + + @property + def all_pumps(self) -> EquipmentDict[Pump]: + """Returns all Pump instances across all bows in the backyard.""" + pumps: list[Pump] = [] + for bow in self.backyard.bow.values(): + pumps.extend(bow.pumps.values()) + return EquipmentDict(pumps) + + @property + def all_filters(self) -> EquipmentDict[Filter]: + """Returns all Filter instances across all bows in the backyard.""" + filters: list[Filter] = [] + for bow in self.backyard.bow.values(): + filters.extend(bow.filters.values()) + return EquipmentDict(filters) + + @property + def all_sensors(self) -> EquipmentDict[Sensor]: + """Returns all Sensor instances across all bows in the backyard.""" + sensors: list[Sensor] = [] + # Sensors at backyard level + sensors.extend(self.backyard.sensors.values()) + # Sensors in each bow + for bow in self.backyard.bow.values(): + sensors.extend(bow.sensors.values()) + return EquipmentDict(sensors) + + @property + def all_heaters(self) -> EquipmentDict[Heater]: + """Returns all Heater (VirtualHeater) instances across all bows in the backyard.""" + heaters: list[Heater] = [] + for bow in self.backyard.bow.values(): + if bow.heater is not None: + heaters.append(bow.heater) + return EquipmentDict(heaters) + + @property + def all_heater_equipment(self) -> EquipmentDict[HeaterEquipment]: + """Returns all HeaterEquipment instances across all heaters in the backyard.""" + heater_equipment: list[HeaterEquipment] = [] + for heater in self.all_heaters.values(): + heater_equipment.extend(heater.heater_equipment.values()) + return EquipmentDict(heater_equipment) + + @property + def all_chlorinators(self) -> EquipmentDict[Chlorinator]: + """Returns all Chlorinator instances across all bows in the backyard.""" + chlorinators: list[Chlorinator] = [] + for bow in self.backyard.bow.values(): + if bow.chlorinator is not None: + chlorinators.append(bow.chlorinator) + return EquipmentDict(chlorinators) + + @property + def all_csads(self) -> EquipmentDict[CSAD]: + """Returns all CSAD instances across all bows in the backyard.""" + csads: list[CSAD] = [] + for bow in self.backyard.bow.values(): + csads.extend(bow.csads.values()) + return EquipmentDict(csads) + + # Equipment search methods + def get_equipment_by_name(self, name: str) -> OmniEquipment[Any, Any] | None: + """ + Find equipment by name across all equipment types. + + Args: + name: The name of the equipment to find + + Returns: + The first equipment with matching name, or None if not found + """ + # Search all equipment types + all_equipment: list[OmniEquipment[Any, Any]] = [] + all_equipment.extend(self.all_lights.values()) + all_equipment.extend(self.all_relays.values()) + all_equipment.extend(self.all_pumps.values()) + all_equipment.extend(self.all_filters.values()) + all_equipment.extend(self.all_sensors.values()) + all_equipment.extend(self.all_heaters.values()) + all_equipment.extend(self.all_heater_equipment.values()) + all_equipment.extend(self.all_chlorinators.values()) + all_equipment.extend(self.all_csads.values()) + + for equipment in all_equipment: + if equipment.name == name: + return equipment + + return None + + def get_equipment_by_id(self, system_id: int) -> OmniEquipment[Any, Any] | None: + """ + Find equipment by system_id across all equipment types. + + Args: + system_id: The system ID of the equipment to find + + Returns: + The first equipment with matching system_id, or None if not found + """ + # Search all equipment types + all_equipment: list[OmniEquipment[Any, Any]] = [] + all_equipment.extend(self.all_lights.values()) + all_equipment.extend(self.all_relays.values()) + all_equipment.extend(self.all_pumps.values()) + all_equipment.extend(self.all_filters.values()) + all_equipment.extend(self.all_sensors.values()) + all_equipment.extend(self.all_heaters.values()) + all_equipment.extend(self.all_heater_equipment.values()) + all_equipment.extend(self.all_chlorinators.values()) + all_equipment.extend(self.all_csads.values()) + + for equipment in all_equipment: + if equipment.system_id == system_id: + return equipment + + return None From 8adcc8278f3d7dadad4b9dc6bc055f940c1d5f45 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:13:53 -0500 Subject: [PATCH 19/61] feat: implement Chlorinator and CSAD equipment properties with comprehensive status decoding - Add telemetry properties and bitmask decoding for Chlorinator status, alerts, and errors - Implement CSAD properties for pH/ORP monitoring and chemical dispensing control - Add computed properties (is_on, is_generating, is_ready, has_alert, etc.) with detailed docstrings - Clarify ORP sensor usage: primary control for chlorinator, monitoring only for CSAD --- pyomnilogic_local/chlorinator.py | 308 ++++++++++++++++++++++++++++++- pyomnilogic_local/csad.py | 283 +++++++++++++++++++++++++++- 2 files changed, 589 insertions(+), 2 deletions(-) diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py index 1692e70..e99c373 100644 --- a/pyomnilogic_local/chlorinator.py +++ b/pyomnilogic_local/chlorinator.py @@ -1,10 +1,316 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.models.mspconfig import MSPChlorinator from pyomnilogic_local.models.telemetry import TelemetryChlorinator +from pyomnilogic_local.omnitypes import ChlorinatorOperatingMode, ChlorinatorStatus class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): - """Represents a chlorinator in the OmniLogic system.""" + """Represents a chlorinator in the OmniLogic system. + + A chlorinator is responsible for generating chlorine through electrolysis + (for salt-based systems) or dispensing chlorine (for liquid/tablet systems). + It monitors and reports salt levels, chlorine generation status, and various + alerts and errors. + + Attributes: + mspconfig: The MSP configuration for this chlorinator + telemetry: Real-time telemetry data for this chlorinator + + Example: + >>> chlorinator = pool.get_chlorinator() + >>> print(f"Salt level: {chlorinator.avg_salt_level} ppm") + >>> print(f"Is generating: {chlorinator.is_generating}") + >>> if chlorinator.has_alert: + ... print(f"Alerts: {chlorinator.alert_messages}") + """ mspconfig: MSPChlorinator telemetry: TelemetryChlorinator + + # Expose MSPConfig attributes + @property + def enabled(self) -> bool: + """Whether the chlorinator is enabled in the system configuration.""" + return self.mspconfig.enabled + + @property + def timed_percent(self) -> int: + """Configured chlorine generation percentage when in timed mode (0-100%).""" + return self.mspconfig.timed_percent + + @property + def superchlor_timeout(self) -> int: + """Timeout duration for super-chlorination mode in minutes.""" + return self.mspconfig.superchlor_timeout + + @property + def orp_timeout(self) -> int: + """Timeout duration for ORP (Oxidation-Reduction Potential) mode in minutes.""" + return self.mspconfig.orp_timeout + + @property + def dispenser_type(self) -> str: + """Type of chlorine dispenser (SALT, LIQUID, or TABLET).""" + return self.mspconfig.dispenser_type + + @property + def cell_type(self) -> str: + """Type of T-Cell installed (e.g., T3, T5, T9, T15).""" + return self.mspconfig.cell_type + + # Expose Telemetry attributes + @property + def operating_state(self) -> int: + """Current operational state of the chlorinator (raw value).""" + return self.telemetry.operating_state + + @property + def operating_mode(self) -> ChlorinatorOperatingMode | int: + """Current operating mode (DISABLED, TIMED, ORP_AUTO, or ORP_TIMED_RW). + + Returns: + ChlorinatorOperatingMode: The operating mode enum value + """ + return self.telemetry.operating_mode + + @property + def timed_percent_telemetry(self) -> int | None: + """Current chlorine generation percentage from telemetry (0-100%). + + This may differ from the configured timed_percent if the system + is in a special mode (e.g., super-chlorination). + + Returns: + Current generation percentage, or None if not available + """ + return self.telemetry.timed_percent + + @property + def sc_mode(self) -> int: + """Super-chlorination mode status (raw value).""" + return self.telemetry.sc_mode + + @property + def avg_salt_level(self) -> int: + """Average salt level reading in parts per million (ppm). + + This is a smoothed reading over time, useful for monitoring + long-term salt levels. + """ + return self.telemetry.avg_salt_level + + @property + def instant_salt_level(self) -> int: + """Instantaneous salt level reading in parts per million (ppm). + + This is the current salt level reading, which may fluctuate + more than the average salt level. + """ + return self.telemetry.instant_salt_level + + # Computed properties for status, alerts, and errors + @property + def status(self) -> list[str]: + """List of active status flags as human-readable strings. + + Decodes the status bitmask into individual flag names. + Possible values include: + - ERROR_PRESENT: An error condition exists (check error_messages) + - ALERT_PRESENT: An alert condition exists (check alert_messages) + - GENERATING: Power is applied to T-Cell, actively chlorinating + - SYSTEM_PAUSED: System processor is pausing chlorination + - LOCAL_PAUSED: Local processor is pausing chlorination + - AUTHENTICATED: T-Cell is authenticated and recognized + - K1_ACTIVE: K1 relay is active + - K2_ACTIVE: K2 relay is active + + Returns: + List of active status flag names + + Example: + >>> chlorinator.status + ['GENERATING', 'AUTHENTICATED', 'K1_ACTIVE'] + """ + return self.telemetry.status + + @property + def alert_messages(self) -> list[str]: + """List of active alert conditions as human-readable strings. + + Decodes the alert bitmask into individual alert names. + Possible values include: + - SALT_LOW: Salt level is low (add salt soon) + - SALT_TOO_LOW: Salt level is too low (add salt now) + - HIGH_CURRENT: High current alert + - LOW_VOLTAGE: Low voltage alert + - CELL_TEMP_LOW: Cell water temperature is low + - CELL_TEMP_SCALEBACK: Cell water temperature scaleback + - CELL_TEMP_HIGH: Cell water temperature is high (bits 4+5 both set) + - BOARD_TEMP_HIGH: Board temperature is high + - BOARD_TEMP_CLEARING: Board temperature is clearing + - CELL_CLEAN: Cell cleaning/runtime alert + + Returns: + List of active alert names + + Example: + >>> chlorinator.alert_messages + ['SALT_LOW', 'CELL_CLEAN'] + """ + return self.telemetry.alerts + + @property + def error_messages(self) -> list[str]: + """List of active error conditions as human-readable strings. + + Decodes the error bitmask into individual error names. + Possible values include: + - CURRENT_SENSOR_SHORT: Current sensor short circuit + - CURRENT_SENSOR_OPEN: Current sensor open circuit + - VOLTAGE_SENSOR_SHORT: Voltage sensor short circuit + - VOLTAGE_SENSOR_OPEN: Voltage sensor open circuit + - CELL_TEMP_SENSOR_SHORT: Cell temperature sensor short + - CELL_TEMP_SENSOR_OPEN: Cell temperature sensor open + - BOARD_TEMP_SENSOR_SHORT: Board temperature sensor short + - BOARD_TEMP_SENSOR_OPEN: Board temperature sensor open + - K1_RELAY_SHORT: K1 relay short circuit + - K1_RELAY_OPEN: K1 relay open circuit + - K2_RELAY_SHORT: K2 relay short circuit + - K2_RELAY_OPEN: K2 relay open circuit + - CELL_ERROR_TYPE: Cell type error + - CELL_ERROR_AUTH: Cell authentication error + - CELL_COMM_LOSS: Cell communication loss (bits 12+13 both set) + - AQUARITE_PCB_ERROR: AquaRite PCB error + + Returns: + List of active error names + + Example: + >>> chlorinator.error_messages + ['CURRENT_SENSOR_SHORT', 'K1_RELAY_OPEN'] + """ + return self.telemetry.errors + + # High-level status properties + @property + def is_on(self) -> bool: + """Check if the chlorinator is currently enabled and operational. + + A chlorinator is considered "on" if it is enabled in the configuration, + regardless of whether it is actively generating chlorine at this moment. + + Returns: + True if the chlorinator is enabled, False otherwise + + See Also: + is_generating: Check if actively producing chlorine right now + """ + return self.enabled and self.telemetry.enable + + @property + def is_generating(self) -> bool: + """Check if the chlorinator is actively generating chlorine. + + This indicates that power is currently applied to the T-Cell and + chlorine is being produced through electrolysis. + + Returns: + True if the GENERATING status flag is set, False otherwise + + Example: + >>> if chlorinator.is_generating: + ... print(f"Generating at {chlorinator.timed_percent_telemetry}%") + """ + return self.telemetry.active + + @property + def is_paused(self) -> bool: + """Check if chlorination is currently paused. + + Chlorination can be paused by either the system processor or the + local processor for various reasons (e.g., low flow, maintenance). + + Returns: + True if either SYSTEM_PAUSED or LOCAL_PAUSED flags are set + + Example: + >>> if chlorinator.is_paused: + ... print("Chlorination is paused") + """ + return bool( + (ChlorinatorStatus.SYSTEM_PAUSED.value & self.telemetry.status_raw) + or (ChlorinatorStatus.LOCAL_PAUSED.value & self.telemetry.status_raw) + ) + + @property + def has_alert(self) -> bool: + """Check if any alert conditions are present. + + Returns: + True if the ALERT_PRESENT status flag is set, False otherwise + + See Also: + alert_messages: Get the list of specific alert conditions + """ + return ChlorinatorStatus.ALERT_PRESENT.value & self.telemetry.status_raw == ChlorinatorStatus.ALERT_PRESENT.value + + @property + def has_error(self) -> bool: + """Check if any error conditions are present. + + Returns: + True if the ERROR_PRESENT status flag is set, False otherwise + + See Also: + error_messages: Get the list of specific error conditions + """ + return ChlorinatorStatus.ERROR_PRESENT.value & self.telemetry.status_raw == ChlorinatorStatus.ERROR_PRESENT.value + + @property + def is_authenticated(self) -> bool: + """Check if the T-Cell is authenticated. + + An authenticated T-Cell is recognized by the system and can generate + chlorine. Unauthenticated cells may be counterfeit or damaged. + + Returns: + True if the AUTHENTICATED status flag is set, False otherwise + """ + return ChlorinatorStatus.AUTHENTICATED.value & self.telemetry.status_raw == ChlorinatorStatus.AUTHENTICATED.value + + @property + def salt_level_status(self) -> str: + """Get a human-readable status of the salt level. + + Returns: + 'OK' if salt level is adequate + 'LOW' if salt is low (add salt soon) + 'TOO_LOW' if salt is too low (add salt now) + + Example: + >>> status = chlorinator.salt_level_status + >>> if status != 'OK': + ... print(f"Salt level is {status}: {chlorinator.avg_salt_level} ppm") + """ + alerts = self.alert_messages + if "SALT_TOO_LOW" in alerts: + return "TOO_LOW" + if "SALT_LOW" in alerts: + return "LOW" + return "OK" + + @property + def is_ready(self) -> bool: + """Check if the chlorinator is ready to accept commands. + + A chlorinator is considered ready if it is authenticated and has no + critical errors that would prevent it from operating. + + Returns: + True if chlorinator can accept commands, False otherwise + + Example: + >>> if chlorinator.is_ready: + ... await chlorinator.set_chlorine_level(75) + """ + return self.is_authenticated and not self.has_error diff --git a/pyomnilogic_local/csad.py b/pyomnilogic_local/csad.py index f362fca..3f59f28 100644 --- a/pyomnilogic_local/csad.py +++ b/pyomnilogic_local/csad.py @@ -1,10 +1,291 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.models.mspconfig import MSPCSAD from pyomnilogic_local.models.telemetry import TelemetryCSAD +from pyomnilogic_local.omnitypes import CSADMode, CSADStatus, CSADType class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]): - """Represents a CSAD in the OmniLogic system.""" + """Represents a CSAD (Chemistry Sense and Dispense) system in the OmniLogic system. + + A CSAD system monitors and automatically dispenses chemicals (typically pH reducer + or CO2) to maintain optimal water chemistry. It continuously measures pH levels + and dispenses treatment chemicals as needed to maintain target pH levels. + + The Chemistry Sense Module (CSM) contains both pH and ORP probes. The pH sensor + output is the primary control input for the CSAD function, while the ORP sensor + output is primarily used by the chlorinator function for automatic chlorine + generation control (though ORP readings are included in CSAD telemetry for + monitoring chlorinator effectiveness). + + Attributes: + mspconfig: The MSP configuration for this CSAD + telemetry: Real-time telemetry data for this CSAD + + Example: + >>> csad = pool.get_csad() + >>> print(f"Current pH: {csad.current_ph}") + >>> print(f"Target pH: {csad.target_ph}") + >>> if csad.is_dispensing: + ... print("Currently dispensing chemicals") + """ mspconfig: MSPCSAD telemetry: TelemetryCSAD + + # Expose MSPConfig attributes + @property + def enabled(self) -> bool: + """Whether the CSAD is enabled in the system configuration.""" + return self.mspconfig.enabled + + @property + def equip_type(self) -> CSADType | str: + """Type of CSAD system (ACID or CO2).""" + return self.mspconfig.equip_type + + @property + def target_ph(self) -> float: + """Target pH level that the CSAD aims to maintain.""" + return self.mspconfig.target_value + + @property + def calibration_value(self) -> float: + """Calibration offset value for pH sensor.""" + return self.mspconfig.calibration_value + + @property + def ph_low_alarm(self) -> float: + """pH level that triggers a low pH alarm.""" + return self.mspconfig.ph_low_alarm_value + + @property + def ph_high_alarm(self) -> float: + """pH level that triggers a high pH alarm.""" + return self.mspconfig.ph_high_alarm_value + + @property + def orp_target_level(self) -> int: + """Target ORP (Oxidation-Reduction Potential) level in millivolts.""" + return self.mspconfig.orp_target_level + + @property + def orp_runtime_level(self) -> int: + """ORP runtime level threshold in millivolts.""" + return self.mspconfig.orp_runtime_level + + @property + def orp_low_alarm_level(self) -> int: + """ORP level that triggers a low ORP alarm in millivolts.""" + return self.mspconfig.orp_low_alarm_level + + @property + def orp_high_alarm_level(self) -> int: + """ORP level that triggers a high ORP alarm in millivolts.""" + return self.mspconfig.orp_high_alarm_level + + @property + def orp_forced_on_time(self) -> int: + """Duration in minutes for forced ORP dispensing mode.""" + return self.mspconfig.orp_forced_on_time + + @property + def orp_forced_enabled(self) -> bool: + """Whether forced ORP dispensing mode is enabled.""" + return self.mspconfig.orp_forced_enabled + + # Expose Telemetry attributes + @property + def status_raw(self) -> int: + """Raw status value from telemetry.""" + return self.telemetry.status_raw + + @property + def current_ph(self) -> float: + """Current pH level reading from the sensor. + + Returns: + Current pH level (typically 0-14, where 7 is neutral) + + Example: + >>> print(f"pH: {csad.current_ph:.2f}") + """ + return self.telemetry.ph + + @property + def current_orp(self) -> int: + """Current ORP (Oxidation-Reduction Potential) reading in millivolts. + + Note: + ORP readings in CSAD telemetry are provided for monitoring purposes to + assess chlorinator effectiveness. The ORP sensor output is primarily + used by the chlorinator function for ORP-based automatic chlorine + generation control. The CSAD system focuses on pH control. + + Returns: + Current ORP level in mV (typically 400-800 mV for pools) + + Example: + >>> print(f"ORP: {csad.current_orp} mV") + """ + return self.telemetry.orp + + @property + def mode(self) -> CSADMode | int: + """Current operating mode of the CSAD. + + Returns: + CSADMode enum value: + - OFF (0): CSAD is off + - AUTO (1): Automatic mode, dispensing as needed + - FORCE_ON (2): Forced dispensing mode + - MONITORING (3): Monitoring only, not dispensing + - DISPENSING_OFF (4): Dispensing is disabled + + Example: + >>> if csad.mode == CSADMode.AUTO: + ... print("CSAD is in automatic mode") + """ + return self.telemetry.mode + + # Computed properties + @property + def state(self) -> CSADStatus: + """Current dispensing state of the CSAD. + + Returns: + CSADStatus.NOT_DISPENSING (0): Not currently dispensing + CSADStatus.DISPENSING (1): Currently dispensing chemicals + + Example: + >>> if csad.state == CSADStatus.DISPENSING: + ... print("Dispensing chemicals") + """ + return CSADStatus(self.status_raw) + + @property + def is_on(self) -> bool: + """Check if the CSAD is currently enabled and operational. + + A CSAD is considered "on" if it is enabled in configuration and + not in OFF mode. + + Returns: + True if the CSAD is enabled and operational, False otherwise + + Example: + >>> if csad.is_on: + ... print(f"CSAD is monitoring pH: {csad.current_ph:.2f}") + """ + return self.enabled and self.mode != CSADMode.OFF + + @property + def is_dispensing(self) -> bool: + """Check if the CSAD is currently dispensing chemicals. + + Returns: + True if actively dispensing, False otherwise + + Example: + >>> if csad.is_dispensing: + ... print(f"Dispensing to reach target pH: {csad.target_ph:.2f}") + """ + return self.state == CSADStatus.DISPENSING + + @property + def has_alert(self) -> bool: + """Check if there are any pH or ORP alerts. + + Checks if current readings are outside the configured alarm thresholds. + + Returns: + True if pH or ORP is outside alarm levels, False otherwise + + Example: + >>> if csad.has_alert: + ... print(f"Alert! {csad.alert_status}") + """ + ph_alert = self.current_ph < self.ph_low_alarm or self.current_ph > self.ph_high_alarm + orp_alert = self.current_orp < self.orp_low_alarm_level or self.current_orp > self.orp_high_alarm_level + return ph_alert or orp_alert + + @property + def alert_status(self) -> str: + """Get a human-readable status of any active alerts. + + Returns: + A descriptive string of alert conditions, or 'OK' if no alerts + + Example: + >>> status = csad.alert_status + >>> if status != 'OK': + ... print(f"Chemistry alert: {status}") + """ + alerts = [] + + if self.current_ph < self.ph_low_alarm: + alerts.append(f"pH too low ({self.current_ph:.2f} < {self.ph_low_alarm:.2f})") + elif self.current_ph > self.ph_high_alarm: + alerts.append(f"pH too high ({self.current_ph:.2f} > {self.ph_high_alarm:.2f})") + + if self.current_orp < self.orp_low_alarm_level: + alerts.append(f"ORP too low ({self.current_orp} < {self.orp_low_alarm_level} mV)") + elif self.current_orp > self.orp_high_alarm_level: + alerts.append(f"ORP too high ({self.current_orp} > {self.orp_high_alarm_level} mV)") + + return "; ".join(alerts) if alerts else "OK" + + @property + def current_value(self) -> float: + """Get the primary current value being monitored (pH for most CSAD systems). + + Returns: + Current pH level + + Note: + For ACID type CSAD, this returns pH. For CO2 type, this also returns pH. + Use current_orp property for ORP readings. + """ + return self.current_ph + + @property + def target_value(self) -> float: + """Get the target value the CSAD is trying to maintain (pH target). + + Returns: + Target pH level + + Note: + This is an alias for target_ph for convenience and consistency + with the task requirements. + """ + return self.target_ph + + @property + def ph_offset(self) -> float: + """Calculate how far the current pH is from the target. + + Returns: + Difference between current and target pH (positive = too high, negative = too low) + + Example: + >>> offset = csad.ph_offset + >>> if offset > 0: + ... print(f"pH is {offset:.2f} points above target") + """ + return self.current_ph - self.target_ph + + @property + def is_ready(self) -> bool: + """Check if the CSAD is ready to accept commands. + + A CSAD is considered ready if it is enabled and in a stable operating mode + (AUTO or MONITORING), not in a transitional or error state. + + Returns: + True if CSAD can accept commands, False otherwise + + Example: + >>> if csad.is_ready: + ... await csad.set_mode(CSADMode.AUTO) + """ + return self.is_on and self.mode in (CSADMode.AUTO, CSADMode.MONITORING, CSADMode.FORCE_ON) From 539f541b59b7fce9ab18391dc0fcf422a8ec16f8 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:35:01 -0500 Subject: [PATCH 20/61] feat: add some missing omnitypes to resolve parsing issues --- pyomnilogic_local/models/mspconfig.py | 7 ++++--- pyomnilogic_local/omnitypes.py | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 791d9e6..6c79c8f 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -217,13 +217,14 @@ class MSPChlorinator(OmniBase): orp_timeout: int = Field(alias="ORP-Timeout") dispenser_type: ChlorinatorDispenserType | str = Field(alias="Dispenser-Type") cell_type: ChlorinatorCellType = Field(alias="Cell-Type") - chlorinator_equipment: list[MSPChlorinatorEquip] | None + chlorinator_equipment: list[MSPChlorinatorEquip] | None = None def __init__(self, **data: Any) -> None: super().__init__(**data) - # The heater equipment are nested down inside a list of "Operations", which also includes non Heater-Equipment items. We need to - # first filter down to just the heater equipment items, then populate our self.heater_equipment with parsed versions of those items. + # The chlorinator equipment are nested down inside a list of "Operations", which also includes non Chlorinator-Equipment items. + # We need to first filter down to just the chlorinator equipment items, then populate our self.chlorinator_equipment with parsed + # versions of those items. chlorinator_equip_data = [op for op in data.get("Operation", {}) if OmniType.CHLORINATOR_EQUIP in op][0] self.chlorinator_equipment = [ MSPChlorinatorEquip.model_validate(equip) for equip in chlorinator_equip_data[OmniType.CHLORINATOR_EQUIP] diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index bc13c59..c9d2f47 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -436,6 +436,8 @@ class HeaterType(StrEnum, PrettyEnum): ELECTRIC = "HTR_ELECTRIC" GEOTHERMAL = "HTR_GEOTHERMAL" SMART = "HTR_SMART" + CHILLER = "HTR_CHILLER" + SMART_HEAT_PUMP = "HTR_SMART_HEAT_PUMP" class HeaterMode(IntEnum, PrettyEnum): @@ -512,6 +514,11 @@ class RelayWhyOn(IntEnum, PrettyEnum): WAITING_FOR_INTERLOCK = 3 PAUSED = 4 WAITING_FOR_FILTER = 5 + UNKNOWN_1 = 6 + UNKNOWN_2 = 7 + UNKNOWN_3 = 8 + UNKNOWN_4 = 9 + UNKNOWN_5 = 10 # Sensors From ff167b91a211fedf470d78d2507f960e7abec7b7 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:09:03 -0500 Subject: [PATCH 21/61] feat: stricter config/telem parsing --- pyomnilogic_local/models/mspconfig.py | 12 ++++++------ pyomnilogic_local/models/telemetry.py | 14 +++++++------- pyomnilogic_local/omnitypes.py | 17 +++++++++-------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 6c79c8f..e159fe3 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -115,8 +115,8 @@ class MSPSystem(BaseModel): class MSPSensor(OmniBase): omni_type: OmniType = OmniType.SENSOR - equip_type: SensorType | str = Field(alias="Type") - units: SensorUnits | str = Field(alias="Units") + equip_type: SensorType = Field(alias="Type") + units: SensorUnits = Field(alias="Units") class MSPFilter(OmniBase): @@ -124,7 +124,7 @@ class MSPFilter(OmniBase): omni_type: OmniType = OmniType.FILTER - equip_type: FilterType | str = Field(alias="Filter-Type") + equip_type: FilterType = Field(alias="Filter-Type") max_percent: int = Field(alias="Max-Pump-Speed") min_percent: int = Field(alias="Min-Pump-Speed") max_rpm: int = Field(alias="Max-Pump-RPM") @@ -141,8 +141,8 @@ class MSPPump(OmniBase): omni_type: OmniType = OmniType.PUMP - equip_type: PumpType | str = Field(alias="Type") - function: PumpFunction | str = Field(alias="Function") + equip_type: PumpType = Field(alias="Type") + function: PumpFunction = Field(alias="Function") max_percent: int = Field(alias="Max-Pump-Speed") min_percent: int = Field(alias="Min-Pump-Speed") max_rpm: int = Field(alias="Max-Pump-RPM") @@ -215,7 +215,7 @@ class MSPChlorinator(OmniBase): timed_percent: int = Field(alias="Timed-Percent") superchlor_timeout: int = Field(alias="SuperChlor-Timeout") orp_timeout: int = Field(alias="ORP-Timeout") - dispenser_type: ChlorinatorDispenserType | str = Field(alias="Dispenser-Type") + dispenser_type: ChlorinatorDispenserType = Field(alias="Dispenser-Type") cell_type: ChlorinatorCellType = Field(alias="Cell-Type") chlorinator_equipment: list[MSPChlorinatorEquip] | None = None diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index 08ee47a..d466ee6 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -60,7 +60,7 @@ class TelemetryBackyard(BaseModel): system_id: int = Field(alias="@systemId") status_version: int = Field(alias="@statusVersion") air_temp: int | None = Field(alias="@airTemp") - state: BackyardState | int = Field(alias="@state") + state: BackyardState = Field(alias="@state") # The below two fields are only available for telemetry with a status_version >= 11 config_checksum: int | None = Field(alias="@ConfigChksum", default=None) msp_version: str | None = Field(alias="@mspVersion", default=None) @@ -88,7 +88,7 @@ class TelemetryChlorinator(BaseModel): sc_mode: int = Field(alias="@scMode") operating_state: int = Field(alias="@operatingState") timed_percent: int | None = Field(alias="@Timed-Percent", default=None) - operating_mode: ChlorinatorOperatingMode | int = Field(alias="@operatingMode") + operating_mode: ChlorinatorOperatingMode = Field(alias="@operatingMode") enable: bool = Field(alias="@enable") @computed_field # type: ignore[prop-decorator] @@ -186,7 +186,7 @@ class TelemetryCSAD(BaseModel): status_raw: int = Field(alias="@status") ph: float = Field(alias="@ph") orp: int = Field(alias="@orp") - mode: CSADMode | int = Field(alias="@mode") + mode: CSADMode = Field(alias="@mode") class TelemetryColorLogicLight(BaseModel): @@ -230,10 +230,10 @@ class TelemetryFilter(BaseModel): omni_type: OmniType = OmniType.FILTER system_id: int = Field(alias="@systemId") - state: FilterState | int = Field(alias="@filterState") + state: FilterState = Field(alias="@filterState") speed: int = Field(alias="@filterSpeed") - valve_position: FilterValvePosition | int = Field(alias="@valvePosition") - why_on: FilterWhyOn | int = Field(alias="@whyFilterIsOn") + valve_position: FilterValvePosition = Field(alias="@valvePosition") + why_on: FilterWhyOn = Field(alias="@whyFilterIsOn") reported_speed: int = Field(alias="@reportedFilterSpeed") power: int = Field(alias="@power") last_speed: int = Field(alias="@lastSpeed") @@ -264,7 +264,7 @@ class TelemetryPump(BaseModel): omni_type: OmniType = OmniType.PUMP system_id: int = Field(alias="@systemId") - state: PumpState | int = Field(alias="@pumpState") + state: PumpState = Field(alias="@pumpState") speed: int = Field(alias="@pumpSpeed") last_speed: int = Field(alias="@lastSpeed") why_on: int = Field(alias="@whyOn") diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index c9d2f47..3e0e621 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -411,9 +411,9 @@ class FilterWhyOn(IntEnum, PrettyEnum): TIMER_SPILLOVER = 13 TIMER_ON = 14 FREEZE_PROTECT = 15 - UNKNOWN_16 = 16 - UNKNOWN_17 = 17 - UNKNOWN_18 = 18 + UNKNOWN_16 = 16 # We have seen 18, so we assume 16 exists + UNKNOWN_17 = 17 # We have seen 18, so we assume 17 exists + UNKNOWN_18 = 18 # ref: https://github.com/cryptk/haomnilogic-local/issues/106 class FilterSpeedPresets(StrEnum, PrettyEnum): @@ -450,6 +450,7 @@ class HeaterMode(IntEnum, PrettyEnum): class PumpState(IntEnum, PrettyEnum): OFF = 0 ON = 1 + FREEZE_PROTECT = 2 # This is an assumption that 2 means freeze protect, ref: https://github.com/cryptk/haomnilogic-local/issues/147 class PumpType(StrEnum, PrettyEnum): @@ -514,11 +515,11 @@ class RelayWhyOn(IntEnum, PrettyEnum): WAITING_FOR_INTERLOCK = 3 PAUSED = 4 WAITING_FOR_FILTER = 5 - UNKNOWN_1 = 6 - UNKNOWN_2 = 7 - UNKNOWN_3 = 8 - UNKNOWN_4 = 9 - UNKNOWN_5 = 10 + UNKNOWN_1 = 6 # We have seen 8, so we assume 6 exists + UNKNOWN_2 = 7 # ref https://github.com/cryptk/haomnilogic-local/issues/150 + UNKNOWN_3 = 8 # ref https://github.com/cryptk/haomnilogic-local/issues/106 + UNKNOWN_4 = 9 # We have seen 10, so we assume 9 exists + UNKNOWN_5 = 10 # ref https://github.com/cryptk/haomnilogic-local/issues/73 # Sensors From 4c8b229298975a8fcf0930f284ad8a0710ebce67 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 1 Nov 2025 11:15:50 -0500 Subject: [PATCH 22/61] feat: update some unknown filter/relay whyOn states --- pyomnilogic_local/omnitypes.py | 40 +++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index 3e0e621..8f4432c 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -398,22 +398,24 @@ class FilterWhyOn(IntEnum, PrettyEnum): OFF = 0 NO_WATER_FLOW = 1 COOLDOWN = 2 - PH_REDUCE_EXTEND = 3 + CSAD_EXTEND = 3 HEATER_EXTEND = 4 - PAUSED = 5 - VALVE_CHANGING = 6 + PAUSE = 5 + OFF_VALVE_CHANGING = 6 FORCE_HIGH_SPEED = 7 - OFF_EXTERNAL_INTERLOCK = 8 + EXTERNAL_INTERLOCK = 8 SUPER_CHLORINATE = 9 - COUNTDOWN = 10 + COUNTDOWN_TIMER = 10 MANUAL_ON = 11 MANUAL_SPILLOVER = 12 - TIMER_SPILLOVER = 13 - TIMER_ON = 14 + TIMED_SPILLOVER = 13 + TIMED_EVENT = 14 FREEZE_PROTECT = 15 - UNKNOWN_16 = 16 # We have seen 18, so we assume 16 exists - UNKNOWN_17 = 17 # We have seen 18, so we assume 17 exists - UNKNOWN_18 = 18 # ref: https://github.com/cryptk/haomnilogic-local/issues/106 + SET_POOL_SPA_SPILLOVER = 16 + SPILLOVER_COUNTDOWN_TIMER = 17 + GROUP_COMMAND = 18 + SPILLOVER_INTERLOCK = 19 + MAX_VALUE = 20 class FilterSpeedPresets(StrEnum, PrettyEnum): @@ -516,10 +518,22 @@ class RelayWhyOn(IntEnum, PrettyEnum): PAUSED = 4 WAITING_FOR_FILTER = 5 UNKNOWN_1 = 6 # We have seen 8, so we assume 6 exists - UNKNOWN_2 = 7 # ref https://github.com/cryptk/haomnilogic-local/issues/150 - UNKNOWN_3 = 8 # ref https://github.com/cryptk/haomnilogic-local/issues/106 + # whyOn value 7 is assumed to be TIMED_EVENT + # ref: https://github.com/cryptk/haomnilogic-local/issues/150 + # ref: https://github.com/cryptk/haomnilogic-local/issues/60 + # the relay in question is a high voltage relay that is scheduled to run 24x7 + TIMED_EVENT = 7 + # whyOn value 8 is assumed to be GROUP_COMMAND + # ref: https://github.com/cryptk/haomnilogic-local/issues/148 + # ref: https://github.com/cryptk/haomnilogic-local/issues/106 + # the relays in question for for a cleaner and water feature that were activated via a running group command + GROUP_COMMAND = 8 UNKNOWN_4 = 9 # We have seen 10, so we assume 9 exists - UNKNOWN_5 = 10 # ref https://github.com/cryptk/haomnilogic-local/issues/73 + # whyOn value 10 is assumed to be EXTERNAL_INTERLOCK + # ref: https://github.com/cryptk/haomnilogic-local/issues/73 + # the relay in question was a high voltage relay controlling an ozonator interlocked with the filter + # and the filter was on due to a timed event + EXTERNAL_INTERLOCK = 10 # Sensors From b0edd9d95f33006df19a63159a4f5d2e2fa2d293 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 1 Nov 2025 12:09:33 -0500 Subject: [PATCH 23/61] feat: add is_ready property to base class and expose all telemetry properties - Implement is_ready in OmniEquipment base class to check backyard service mode state - Update all equipment classes to call super().is_ready before their own readiness checks - Add telemetry properties to Backyard - Add telemetry and config properties to Bow --- pyomnilogic_local/_base.py | 23 +++++++++++++ pyomnilogic_local/backyard.py | 49 ++++++++++++++++++++++++++++ pyomnilogic_local/bow.py | 27 +++++++++++++-- pyomnilogic_local/chlorinator.py | 11 +++++-- pyomnilogic_local/colorlogiclight.py | 17 +++++++--- pyomnilogic_local/csad.py | 11 +++++-- pyomnilogic_local/filter.py | 10 ++++-- pyomnilogic_local/pump.py | 9 ++++- 8 files changed, 143 insertions(+), 14 deletions(-) diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py index 1fffa00..c9f7609 100644 --- a/pyomnilogic_local/_base.py +++ b/pyomnilogic_local/_base.py @@ -4,6 +4,7 @@ from pyomnilogic_local.api.api import OmniLogicAPI from pyomnilogic_local.models import MSPEquipmentType, Telemetry from pyomnilogic_local.models.telemetry import TelemetryType +from pyomnilogic_local.omnitypes import BackyardState if TYPE_CHECKING: from pyomnilogic_local.omnilogic import OmniLogic @@ -67,6 +68,28 @@ def omni_type(self) -> str | None: """The OmniType of the equipment.""" return self.mspconfig.omni_type + @property + def is_ready(self) -> bool: + """Check if the equipment is ready to accept commands. + + Equipment is not ready when the backyard is in service or configuration mode. + This is the base implementation that checks backyard state. + Subclasses should call super().is_ready first and add their own checks. + + Returns: + bool: False if backyard is in SERVICE_MODE, CONFIG_MODE, or TIMED_SERVICE_MODE, + True otherwise (equipment-specific checks in subclasses) + """ + # Check if backyard state allows equipment operations + backyard_state = self._omni.backyard.telemetry.state + if backyard_state in ( + BackyardState.SERVICE_MODE, + BackyardState.CONFIG_MODE, + BackyardState.TIMED_SERVICE_MODE, + ): + return False + return True + def update(self, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: """Update both the configuration and telemetry data for the equipment.""" self.update_config(mspconfig) diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py index 3546358..4b12428 100644 --- a/pyomnilogic_local/backyard.py +++ b/pyomnilogic_local/backyard.py @@ -4,6 +4,7 @@ from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.models.mspconfig import MSPBackyard from pyomnilogic_local.models.telemetry import Telemetry, TelemetryBackyard +from pyomnilogic_local.omnitypes import BackyardState from ._base import OmniEquipment from .bow import Bow @@ -30,6 +31,54 @@ class Backyard(OmniEquipment[MSPBackyard, TelemetryBackyard]): def __init__(self, omni: "OmniLogic", mspconfig: MSPBackyard, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) + @property + def status_version(self) -> int: + """Telemetry status version number.""" + return self.telemetry.status_version + + @property + def air_temp(self) -> int | None: + """Current air temperature reading from the backyard sensor. + + Note: Temperature is in Fahrenheit. May be None if sensor is not available. + """ + return self.telemetry.air_temp + + @property + def state(self) -> BackyardState: + """Current backyard state (OFF, ON, SERVICE_MODE, CONFIG_MODE, TIMED_SERVICE_MODE).""" + return self.telemetry.state + + @property + def config_checksum(self) -> int | None: + """Configuration checksum value. + + Note: Only available when status_version >= 11. Returns None otherwise. + """ + return self.telemetry.config_checksum + + @property + def msp_version(self) -> str | None: + """MSP firmware version string. + + Note: Only available when status_version >= 11. Returns None otherwise. + Example: "R0408000" + """ + return self.telemetry.msp_version + + @property + def is_service_mode(self) -> bool: + """Check if the backyard is in any service mode. + + Returns: + True if in SERVICE_MODE, CONFIG_MODE, or TIMED_SERVICE_MODE, False otherwise + """ + return self.state in ( + BackyardState.SERVICE_MODE, + BackyardState.CONFIG_MODE, + BackyardState.TIMED_SERVICE_MODE, + ) + def _update_equipment(self, mspconfig: MSPBackyard, telemetry: Telemetry | None) -> None: """Update both the configuration and telemetry data for the equipment.""" if telemetry is None: diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index c211d6f..a0b82d5 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -9,6 +9,7 @@ from pyomnilogic_local.heater import Heater from pyomnilogic_local.models.mspconfig import MSPBoW from pyomnilogic_local.models.telemetry import Telemetry, TelemetryBoW +from pyomnilogic_local.omnitypes import BodyOfWaterType from pyomnilogic_local.pump import Pump from pyomnilogic_local.relay import Relay from pyomnilogic_local.sensor import Sensor @@ -35,10 +36,32 @@ def __init__(self, omni: "OmniLogic", mspconfig: MSPBoW, telemetry: Telemetry) - super().__init__(omni, mspconfig, telemetry) @property - def equip_type(self) -> str: - """The equipment type of the bow.""" + def equip_type(self) -> BodyOfWaterType | str: + """The equipment type of the bow (POOL or SPA).""" return self.mspconfig.equip_type + @property + def supports_spillover(self) -> bool: + """Whether this body of water supports spillover functionality.""" + return self.mspconfig.supports_spillover + + @property + def water_temp(self) -> int: + """Current water temperature reading from the bow sensor. + + Note: Temperature is in Fahrenheit. Returns -1 if sensor is not available. + """ + return self.telemetry.water_temp + + @property + def flow(self) -> int: + """Current flow sensor reading. + + Returns: + Flow value (255 typically indicates flow present, 0 indicates no flow) + """ + return self.telemetry.flow + def _update_equipment(self, mspconfig: MSPBoW, telemetry: Telemetry | None) -> None: """Update both the configuration and telemetry data for the equipment.""" if telemetry is None: diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py index e99c373..ec33b62 100644 --- a/pyomnilogic_local/chlorinator.py +++ b/pyomnilogic_local/chlorinator.py @@ -303,8 +303,10 @@ def salt_level_status(self) -> str: def is_ready(self) -> bool: """Check if the chlorinator is ready to accept commands. - A chlorinator is considered ready if it is authenticated and has no - critical errors that would prevent it from operating. + A chlorinator is considered ready if: + - The backyard is not in service/config mode (checked by parent class) + - It is authenticated + - It has no critical errors that would prevent it from operating Returns: True if chlorinator can accept commands, False otherwise @@ -313,4 +315,9 @@ def is_ready(self) -> bool: >>> if chlorinator.is_ready: ... await chlorinator.set_chlorine_level(75) """ + # First check if backyard is ready + if not super().is_ready: + return False + + # Then check chlorinator-specific readiness return self.is_authenticated and not self.has_error diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index dfe9d24..4983ed4 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -83,15 +83,22 @@ def is_ready(self) -> bool: """ Returns whether the light is ready to accept commands. - The light is not ready when it is in a transitional state: - - FIFTEEN_SECONDS_WHITE: Light is in the 15-second white period after power on - - CHANGING_SHOW: Light is actively changing between shows - - POWERING_OFF: Light is in the process of turning off - - COOLDOWN: Light is in cooldown period after being turned off + The light is not ready when: + - The backyard is in service/config mode (checked by parent class) + - The light is in a transitional state: + - FIFTEEN_SECONDS_WHITE: Light is in the 15-second white period after power on + - CHANGING_SHOW: Light is actively changing between shows + - POWERING_OFF: Light is in the process of turning off + - COOLDOWN: Light is in cooldown period after being turned off Returns: bool: True if the light can accept commands, False otherwise. """ + # First check if backyard is ready + if not super().is_ready: + return False + + # Then check light-specific readiness return self.state not in [ ColorLogicPowerState.FIFTEEN_SECONDS_WHITE, ColorLogicPowerState.CHANGING_SHOW, diff --git a/pyomnilogic_local/csad.py b/pyomnilogic_local/csad.py index 3f59f28..8eee1e2 100644 --- a/pyomnilogic_local/csad.py +++ b/pyomnilogic_local/csad.py @@ -278,8 +278,10 @@ def ph_offset(self) -> float: def is_ready(self) -> bool: """Check if the CSAD is ready to accept commands. - A CSAD is considered ready if it is enabled and in a stable operating mode - (AUTO or MONITORING), not in a transitional or error state. + A CSAD is considered ready if: + - The backyard is not in service/config mode (checked by parent class) + - It is enabled and in a stable operating mode (AUTO, MONITORING, or FORCE_ON) + - Not in a transitional or error state Returns: True if CSAD can accept commands, False otherwise @@ -288,4 +290,9 @@ def is_ready(self) -> bool: >>> if csad.is_ready: ... await csad.set_mode(CSADMode.AUTO) """ + # First check if backyard is ready + if not super().is_ready: + return False + + # Then check CSAD-specific readiness return self.is_on and self.mode in (CSADMode.AUTO, CSADMode.MONITORING, CSADMode.FORCE_ON) diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py index 9bf3395..55e4a9e 100644 --- a/pyomnilogic_local/filter.py +++ b/pyomnilogic_local/filter.py @@ -115,12 +115,18 @@ def is_on(self) -> bool: def is_ready(self) -> bool: """Check if the filter is ready to receive commands. - A filter is considered ready if it's not in a transitional state like - priming, waiting to turn off, or cooling down. + A filter is considered ready if: + - The backyard is not in service/config mode (checked by parent class) + - It's not in a transitional state like priming, waiting to turn off, or cooling down Returns: True if filter can accept commands, False otherwise """ + # First check if backyard is ready + if not super().is_ready: + return False + + # Then check filter-specific readiness return self.state in (FilterState.OFF, FilterState.ON) # Control methods diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index 53118d6..2b5d823 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -98,11 +98,18 @@ def is_on(self) -> bool: def is_ready(self) -> bool: """Check if the pump is ready to receive commands. - A pump is considered ready if it's in a stable state (ON or OFF). + A pump is considered ready if: + - The backyard is not in service/config mode (checked by parent class) + - It's in a stable state (ON or OFF) Returns: True if pump can accept commands, False otherwise """ + # First check if backyard is ready + if not super().is_ready: + return False + + # Then check pump-specific readiness return self.state in (PumpState.OFF, PumpState.ON) # Control methods From a03a32118655ca83b35cab9bc91cdc63fcd37a29 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:01:16 -0500 Subject: [PATCH 24/61] feat: add control methods to chlorinators --- pyomnilogic_local/chlorinator.py | 78 ++++++++++++++++++++++++++- pyomnilogic_local/models/mspconfig.py | 15 ++++++ pyomnilogic_local/omnitypes.py | 42 +++++---------- 3 files changed, 103 insertions(+), 32 deletions(-) diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py index ec33b62..6a870b0 100644 --- a/pyomnilogic_local/chlorinator.py +++ b/pyomnilogic_local/chlorinator.py @@ -1,7 +1,13 @@ from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPChlorinator from pyomnilogic_local.models.telemetry import TelemetryChlorinator -from pyomnilogic_local.omnitypes import ChlorinatorOperatingMode, ChlorinatorStatus +from pyomnilogic_local.omnitypes import ( + ChlorinatorCellType, + ChlorinatorOperatingMode, + ChlorinatorStatus, +) +from pyomnilogic_local.util import OmniEquipmentNotInitializedError class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): @@ -54,7 +60,7 @@ def dispenser_type(self) -> str: return self.mspconfig.dispenser_type @property - def cell_type(self) -> str: + def cell_type(self) -> ChlorinatorCellType: """Type of T-Cell installed (e.g., T3, T5, T9, T15).""" return self.mspconfig.cell_type @@ -321,3 +327,71 @@ def is_ready(self) -> bool: # Then check chlorinator-specific readiness return self.is_authenticated and not self.has_error + + # Control methods + @dirties_state() + async def turn_on(self) -> None: + """Turn the chlorinator on (enable it). + + Raises: + OmniEquipmentNotInitializedError: If bow_id is None. + """ + if self.bow_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn on chlorinator: bow_id is None") + await self._api.async_set_chlorinator_enable(self.bow_id, True) + + @dirties_state() + async def turn_off(self) -> None: + """Turn the chlorinator off (disable it). + + Raises: + OmniEquipmentNotInitializedError: If bow_id is None. + """ + if self.bow_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn off chlorinator: bow_id is None") + await self._api.async_set_chlorinator_enable(self.bow_id, False) + + @dirties_state() + async def set_timed_percent(self, percent: int) -> None: + """Set the timed percent for chlorine generation. + + Args: + percent: The chlorine generation percentage (0-100) + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If percent is outside the valid range (0-100). + + Note: + This method uses the async_set_chlorinator_params API which requires + all chlorinator configuration parameters. The current values from + mspconfig are used for unchanged parameters. + """ + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot set timed percent: bow_id or system_id is None") + + if not 0 <= percent <= 100: + raise ValueError(f"Timed percent {percent} is outside valid range [0, 100]") + + # Get the parent Bow to determine bow_type + # We need to find our bow in the backyard + if (bow := self._omni.backyard.bow.get(self.bow_id)) is None: + raise OmniEquipmentNotInitializedError(f"Cannot find bow with id {self.bow_id}") + + # Map equipment type to numeric bow_type value + # BOW_POOL = 0, BOW_SPA = 1 (based on typical protocol values) + bow_type = 0 if bow.equip_type == "BOW_POOL" else 1 + + # Get operating mode from telemetry (it's already an int or enum with .value) + op_mode = self.telemetry.operating_mode if isinstance(self.telemetry.operating_mode, int) else self.telemetry.operating_mode.value + + await self._api.async_set_chlorinator_params( + pool_id=self.bow_id, + equipment_id=self.system_id, + timed_percent=percent, + cell_type=self.mspconfig.cell_type.value, # ChlorinatorCellType is now IntEnum, use .value + op_mode=op_mode, + sc_timeout=self.mspconfig.superchlor_timeout, + bow_type=bow_type, + orp_timeout=self.mspconfig.orp_timeout, + ) diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index e159fe3..c9901bc 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -219,6 +219,21 @@ class MSPChlorinator(OmniBase): cell_type: ChlorinatorCellType = Field(alias="Cell-Type") chlorinator_equipment: list[MSPChlorinatorEquip] | None = None + @model_validator(mode="before") + @classmethod + def convert_cell_type(cls, data: Any) -> Any: + """Convert cell_type string to ChlorinatorCellType enum by name.""" + if isinstance(data, dict) and "Cell-Type" in data: + cell_type_str = data["Cell-Type"] + if isinstance(cell_type_str, str): + # Parse by enum member name (e.g., "CELL_TYPE_T15" -> ChlorinatorCellType.CELL_TYPE_T15) + try: + data["Cell-Type"] = ChlorinatorCellType[cell_type_str] + except KeyError: + # If not found, try to parse as int or leave as-is for Pydantic to handle + pass + return data + def __init__(self, **data: Any) -> None: super().__init__(**data) diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index 8f4432c..bcd6931 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -155,36 +155,18 @@ class ChlorinatorDispenserType(StrEnum, PrettyEnum): TABLET = "TABLET_DISPENSING" -class ChlorinatorCellType(StrEnum, PrettyEnum): - UNKNOWN = "CELL_TYPE_UNKNOWN" - T3 = "CELL_TYPE_T3" - T5 = "CELL_TYPE_T5" - T9 = "CELL_TYPE_T9" - T15 = "CELL_TYPE_T15" - T15_LS = "CELL_TYPE_T15_LS" - TCELLS315 = "CELL_TYPE_TCELLS315" - TCELLS325 = "CELL_TYPE_TCELLS325" - TCELLS340 = "CELL_TYPE_TCELLS340" - LIQUID = "CELL_TYPE_LIQUID" - TABLET = "CELL_TYPE_TABLET" - - # There is probably an easier way to do this - def __int__(self) -> int: - return ChlorinatorCellInt[self.name].value - - -class ChlorinatorCellInt(IntEnum, PrettyEnum): - UNKNOWN = 0 - T3 = 1 - T5 = 2 - T9 = 3 - T15 = 4 - T15_LS = 5 - TCELLS315 = 6 - TCELLS325 = 7 - TCELLS340 = 8 - LIQUID = 9 - TABLET = 10 +class ChlorinatorCellType(IntEnum, PrettyEnum): + CELL_TYPE_UNKNOWN = 0 + CELL_TYPE_T3 = 1 + CELL_TYPE_T5 = 2 + CELL_TYPE_T9 = 3 + CELL_TYPE_T15 = 4 + CELL_TYPE_T15_LS = 5 + CELL_TYPE_TCELLS315 = 6 + CELL_TYPE_TCELLS325 = 7 + CELL_TYPE_TCELLS340 = 8 + CELL_TYPE_LIQUID = 9 + CELL_TYPE_TABLET = 10 # Lights From c8328a511be6a9bd255ebac491d11873ea8713b6 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:06:56 -0500 Subject: [PATCH 25/61] feat: add more control methods to pumps and filters --- pyomnilogic_local/filter.py | 26 +++++++++++++++ pyomnilogic_local/omnitypes.py | 6 ++++ pyomnilogic_local/pump.py | 61 +++++++++++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py index 55e4a9e..10633b6 100644 --- a/pyomnilogic_local/filter.py +++ b/pyomnilogic_local/filter.py @@ -187,3 +187,29 @@ async def run_preset_speed(self, speed: FilterSpeedPresets) -> None: equipment_id=self.system_id, is_on=speed_value, ) + + @dirties_state() + async def set_speed(self, speed: int) -> None: + """Set the filter to a specific speed. + + Args: + speed: Speed value (0-100 percent). A value of 0 will turn the filter off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If speed is outside the valid range. + """ + if self.bow_id is None or self.system_id is None: + msg = "Filter bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + if not 0 <= speed <= 100: + msg = f"Speed {speed} is outside valid range [0, 100]" + raise ValueError(msg) + + # Note: The API validates against min_percent/max_percent internally + await self._api.async_set_filter_speed( + pool_id=self.bow_id, + equipment_id=self.system_id, + speed=speed, + ) diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index bcd6931..e54751c 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -460,6 +460,12 @@ class PumpFunction(StrEnum, PrettyEnum): CLEANER_IN_FLOOR = "PMP_CLEANER_IN_FLOOR" +class PumpSpeedPresets(StrEnum, PrettyEnum): + LOW = auto() + MEDIUM = auto() + HIGH = auto() + + # Relays class RelayFunction(StrEnum, PrettyEnum): WATER_FEATURE = "RLY_WATER_FEATURE" diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index 2b5d823..f404d84 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -2,7 +2,7 @@ from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPPump from pyomnilogic_local.models.telemetry import TelemetryPump -from pyomnilogic_local.omnitypes import PumpState +from pyomnilogic_local.omnitypes import PumpSpeedPresets, PumpState from pyomnilogic_local.util import OmniEquipmentNotInitializedError @@ -141,3 +141,62 @@ async def turn_off(self) -> None: equipment_id=self.system_id, is_on=False, ) + + @dirties_state() + async def run_preset_speed(self, speed: PumpSpeedPresets) -> None: + """Run the pump at a preset speed. + + Args: + speed: The preset speed to use (LOW, MEDIUM, or HIGH) + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If an invalid speed preset is provided. + """ + if self.bow_id is None or self.system_id is None: + msg = "Pump bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + speed_value: int + match speed: + case PumpSpeedPresets.LOW: + speed_value = self.low_speed + case PumpSpeedPresets.MEDIUM: + speed_value = self.medium_speed + case PumpSpeedPresets.HIGH: + speed_value = self.high_speed + case _: + msg = f"Invalid speed preset: {speed}" + raise ValueError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=speed_value, + ) + + @dirties_state() + async def set_speed(self, speed: int) -> None: + """Set the pump to a specific speed. + + Args: + speed: Speed value (0-100 percent). A value of 0 will turn the pump off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If speed is outside the valid range. + """ + if self.bow_id is None or self.system_id is None: + msg = "Pump bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + if not 0 <= speed <= 100: + msg = f"Speed {speed} is outside valid range [0, 100]" + raise ValueError(msg) + + # Note: The API validates against min_percent/max_percent internally + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=speed, + ) From 829f10534cd95f812d1647c7900135cf524f0f58 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:09:28 -0500 Subject: [PATCH 26/61] feat: add spillover control methods --- pyomnilogic_local/bow.py | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index a0b82d5..8e10e99 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -5,6 +5,7 @@ from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.colorlogiclight import _LOGGER, ColorLogicLight from pyomnilogic_local.csad import CSAD +from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.filter import Filter from pyomnilogic_local.heater import Heater from pyomnilogic_local.models.mspconfig import MSPBoW @@ -13,6 +14,7 @@ from pyomnilogic_local.pump import Pump from pyomnilogic_local.relay import Relay from pyomnilogic_local.sensor import Sensor +from pyomnilogic_local.util import OmniEquipmentNotInitializedError if TYPE_CHECKING: from pyomnilogic_local.omnilogic import OmniLogic @@ -62,6 +64,58 @@ def flow(self) -> int: """ return self.telemetry.flow + # Control methods + @dirties_state() + async def set_spillover(self, speed: int) -> None: + """Set the spillover speed for this body of water. + + Spillover allows water to flow between pool and spa. This method sets + the speed at which the spillover pump operates. + + Args: + speed: Spillover speed value (0-100 percent). A value of 0 will turn spillover off. + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + ValueError: If spillover is not supported by this body of water. + """ + if self.system_id is None: + msg = "Bow system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + if not self.supports_spillover: + msg = f"Spillover is not supported by {self.name}" + raise ValueError(msg) + + await self._api.async_set_spillover( + pool_id=self.system_id, + speed=speed, + ) + + @dirties_state() + async def turn_on_spillover(self) -> None: + """Turn on spillover at maximum speed (100%). + + This is a convenience method that calls set_spillover(100). + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + ValueError: If spillover is not supported by this body of water. + """ + await self.set_spillover(100) + + @dirties_state() + async def turn_off_spillover(self) -> None: + """Turn off spillover. + + This is a convenience method that calls set_spillover(0). + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + ValueError: If spillover is not supported by this body of water. + """ + await self.set_spillover(0) + def _update_equipment(self, mspconfig: MSPBoW, telemetry: Telemetry | None) -> None: """Update both the configuration and telemetry data for the equipment.""" if telemetry is None: From 58f684feecb81f41ee8c2afb0a3f2dc8dc96c780 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 1 Nov 2025 16:28:23 -0500 Subject: [PATCH 27/61] docs: enhance docstrings for all equipment classes --- pyomnilogic_local/_base.py | 35 ++++++++- pyomnilogic_local/backyard.py | 73 ++++++++++++++++- pyomnilogic_local/bow.py | 113 ++++++++++++++++++++++++++- pyomnilogic_local/colorlogiclight.py | 85 +++++++++++++++++++- pyomnilogic_local/filter.py | 67 +++++++++++++++- pyomnilogic_local/heater.py | 87 +++++++++++++++++++-- pyomnilogic_local/heater_equip.py | 75 ++++++++++++++++-- pyomnilogic_local/pump.py | 66 +++++++++++++++- pyomnilogic_local/relay.py | 56 ++++++++++++- pyomnilogic_local/sensor.py | 69 +++++++++++++++- 10 files changed, 696 insertions(+), 30 deletions(-) diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py index c9f7609..c2d3fe2 100644 --- a/pyomnilogic_local/_base.py +++ b/pyomnilogic_local/_base.py @@ -18,11 +18,40 @@ class OmniEquipment(Generic[MSPConfigT, TelemetryT]): - """Base class for OmniLogic equipment. + """Base class for all OmniLogic equipment. - Generic parameters: + This is an abstract base class that provides common functionality for all equipment + types in the OmniLogic system. It handles configuration updates, telemetry updates, + and provides access to the API for control operations. + + All equipment classes inherit from this base and are strongly typed using generic + parameters for their specific configuration and telemetry types. + + Generic Parameters: MSPConfigT: The specific MSP configuration type (e.g., MSPBoW, MSPRelay) - TelemetryT: The specific telemetry type (e.g., TelemetryBoW, TelemetryRelay, or None for equipment without telemetry) + TelemetryT: The specific telemetry type (e.g., TelemetryBoW, TelemetryRelay, or None) + + Attributes: + mspconfig: Configuration data from the MSP XML + telemetry: Live telemetry data (may be None for equipment without telemetry) + child_equipment: Dictionary of child equipment indexed by system_id + + Properties: + bow_id: The body of water ID this equipment belongs to + name: Equipment name from configuration + system_id: Unique system identifier + omni_type: OmniLogic type identifier + is_ready: Whether equipment can accept commands (checks backyard state) + + Example: + Equipment classes should not be instantiated directly. Access them through + the OmniLogic instance: + + >>> omni = OmniLogic("192.168.1.100") + >>> await omni.refresh() + >>> # Access equipment through the backyard + >>> pool = omni.backyard.bow["Pool"] + >>> pump = pool.pumps["Main Pump"] """ mspconfig: MSPConfigT diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py index 4b12428..22c610e 100644 --- a/pyomnilogic_local/backyard.py +++ b/pyomnilogic_local/backyard.py @@ -19,7 +19,78 @@ class Backyard(OmniEquipment[MSPBackyard, TelemetryBackyard]): - """Represents the backyard equipment in the OmniLogic system.""" + """Represents the backyard (top-level equipment container) in the OmniLogic system. + + The Backyard is the root equipment container that holds all pool and spa + equipment. It contains: + - Bodies of Water (BoW): Pools and spas + - Backyard-level lights: Lights not associated with a specific body of water + - Backyard-level relays: Auxiliary equipment, landscape lighting, etc. + - Backyard-level sensors: Air temperature, etc. + + The Backyard also provides system-wide status information including air + temperature, service mode state, and firmware version. + + Attributes: + mspconfig: Configuration data for the backyard + telemetry: Real-time system-wide telemetry + bow: Collection of Bodies of Water (pools/spas) + lights: Collection of backyard-level lights + relays: Collection of backyard-level relays + sensors: Collection of backyard-level sensors + + Properties (Telemetry): + status_version: Telemetry protocol version number + air_temp: Current air temperature (Fahrenheit) + state: System state (ON, OFF, SERVICE_MODE, CONFIG_MODE, etc.) + config_checksum: Configuration checksum (status_version 11+) + msp_version: MSP firmware version string (status_version 11+) + is_service_mode: True if in any service/config mode + + Example: + >>> omni = OmniLogic("192.168.1.100") + >>> await omni.refresh() + >>> + >>> # Access backyard + >>> backyard = omni.backyard + >>> + >>> # Check system status + >>> print(f"Air temp: {backyard.air_temp}°F") + >>> print(f"System state: {backyard.state}") + >>> print(f"Firmware: {backyard.msp_version}") + >>> + >>> # Check service mode + >>> if backyard.is_service_mode: + ... print("System is in service mode - equipment cannot be controlled") + >>> + >>> # Access bodies of water + >>> for bow in backyard.bow: + ... print(f"Body of Water: {bow.name} ({bow.equip_type})") + >>> + >>> # Access backyard equipment + >>> for light in backyard.lights: + ... print(f"Backyard light: {light.name}") + >>> + >>> for relay in backyard.relays: + ... print(f"Backyard relay: {relay.name}") + + Service Mode: + When the backyard is in service mode (SERVICE_MODE, CONFIG_MODE, or + TIMED_SERVICE_MODE), equipment control is disabled. This typically + occurs during: + - System maintenance + - Configuration changes + - Timed service operations + + Always check is_service_mode or is_ready before controlling equipment. + + Note: + - The Backyard is the root of the equipment hierarchy + - All equipment belongs to either the Backyard or a Body of Water + - Service mode blocks all equipment control operations + - Configuration changes require backyard state changes + - Air temperature sensor must be configured for air_temp readings + """ mspconfig: MSPBackyard telemetry: TelemetryBackyard diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index 8e10e99..8d0481d 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -21,7 +21,108 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): - """Represents a bow in the OmniLogic system.""" + """Represents a Body of Water (BoW) - pool or spa - in the OmniLogic system. + + A Body of Water (commonly abbreviated as BoW) is a pool or spa, along with + all of its associated equipment. Each BoW contains: + - Filtration pumps + - Heating equipment + - Chlorination/sanitization systems + - Chemistry monitoring (CSAD) + - Lighting + - Auxiliary pumps (water features, etc.) + - Relays (jets, blowers, etc.) + - Sensors (water temperature, flow, etc.) + + The Bow class provides access to all equipment associated with a specific + pool or spa, as well as water temperature monitoring and spillover control + for pool/spa combination systems. + + Attributes: + mspconfig: Configuration data for this body of water + telemetry: Real-time operational data + filters: Collection of filtration pumps + heater: Virtual heater (if configured) + relays: Collection of relays (jets, blowers, aux equipment) + sensors: Collection of sensors (water temp, flow, etc.) + lights: Collection of ColorLogic lights + pumps: Collection of pumps (water features, etc.) + chlorinator: Chlorinator system (if configured) + csads: Collection of CSAD (chemistry) systems + + Properties (Configuration): + equip_type: Body of water type (BOW_POOL or BOW_SPA) + supports_spillover: Whether spillover is available + + Properties (Telemetry): + water_temp: Current water temperature (Fahrenheit) + flow: True if flow is detected, False otherwise + + Control Methods: + set_spillover(speed): Set spillover pump speed (0-100%) + turn_on_spillover(): Turn on spillover at maximum speed + turn_off_spillover(): Turn off spillover + + Example: + >>> omni = OmniLogic("192.168.1.100") + >>> await omni.refresh() + >>> + >>> # Access pool + >>> pool = omni.backyard.bow["Pool"] + >>> print(f"Water temp: {pool.water_temp}°F") + >>> print(f"Flow detected: {pool.flow > 0}") + >>> + >>> # Access pool equipment + >>> if pool.heater: + ... await pool.heater.set_temperature(85) + >>> + >>> if pool.chlorinator: + ... print(f"Salt level: {pool.chlorinator.avg_salt_level} ppm") + >>> + >>> for filter in pool.filters: + ... print(f"Filter: {filter.name}, Speed: {filter.speed}%") + >>> + >>> for light in pool.lights: + ... await light.set_show(ColorLogicShow25.TROPICAL) + >>> + >>> # Spillover control (pool/spa combo systems) + >>> if pool.supports_spillover: + ... await pool.turn_on_spillover() + ... await pool.set_spillover(75) # 75% speed + ... await pool.turn_off_spillover() + + Pool vs Spa: + Bodies of water can be either pools or spas, distinguished by the + equip_type property: + + >>> if pool.equip_type == BodyOfWaterType.POOL: + ... print("This is a pool") + >>> elif pool.equip_type == BodyOfWaterType.SPA: + ... print("This is a spa") + + Spillover Systems: + Some installations have combined pool/spa systems with spillover + capability that allows water to flow from spa to pool or vice versa: + + - supports_spillover indicates if the feature is available + - Spillover is controlled by a dedicated pump + - Speed range is 0-100% (0 turns spillover off) + - Convenience methods simplify on/off operations + + Equipment Collections: + Equipment is stored in EquipmentDict collections which allow access by: + - Name (string): pool.filters["Main Filter"] + - System ID (int): pool.filters[123] + - Index (int): pool.filters[0] + - Iteration: for filter in pool.filters: ... + + Note: + - Water temperature returns -1 if sensor not available + - Flow telemetry typically reads 255 or 1 for flow, 0 for no flow, we simplify to bool + - Not all bodies of water have all equipment types + - Some equipment (heater, chlorinator) may be None if not configured + - Spillover operations raise ValueError if not supported + """ mspconfig: MSPBoW telemetry: TelemetryBoW @@ -56,13 +157,17 @@ def water_temp(self) -> int: return self.telemetry.water_temp @property - def flow(self) -> int: + def flow(self) -> bool: """Current flow sensor reading. Returns: - Flow value (255 typically indicates flow present, 0 indicates no flow) + bool: True if flow is present, False otherwise. """ - return self.telemetry.flow + # Flow values: + # 255 seems to indicate "assumed flow", for example, because a filter pump is on + # 1 seems to indicate "certain flow", for example, when there is an actual flow sensor + # 0 indicates no flow + return self.telemetry.flow > 0 # Control methods @dirties_state() diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index 4983ed4..ed81040 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -24,7 +24,90 @@ class ColorLogicLight(OmniEquipment[MSPColorLogicLight, TelemetryColorLogicLight]): - """Represents a color logic light.""" + """Represents a ColorLogic or compatible LED light in the OmniLogic system. + + ColorLogic lights are intelligent LED pool and spa lights that can display + various color shows, patterns, and effects. The OmniLogic system supports + multiple light models with different capabilities: + + Light Models: + - ColorLogic 2.5: Classic model with 10 preset shows + - ColorLogic 4.0: Enhanced model with 15 preset shows + - ColorLogic UCL (UniversaLogic): Universal controller for various lights + - ColorLogic SAM: Smart lighting system + - Pentair and Zodiac compatible lights (limited functionality) + + Each light model supports different shows, speeds, and brightness levels. + The ColorLogicLight class automatically handles these differences. + + Attributes: + mspconfig: Configuration data for this light from MSP XML + telemetry: Real-time operational state and settings + + Properties (Configuration): + model: Light model type (TWO_FIVE, FOUR_ZERO, UCL, SAM, etc.) + v2_active: Whether V2 protocol features are active + effects: List of available light shows for this model + + Properties (Telemetry): + state: Current power state (ON, OFF, transitional states) + show: Currently selected light show + speed: Show animation speed (1x, 2x, 4x, 8x) + brightness: Light brightness (25%, 50%, 75%, 100%) + special_effect: Special effect setting + + Properties (Computed): + is_on: True if light is currently on + is_ready: True if light can accept commands (not in transitional state) + + Control Methods: + turn_on(): Turn on light (starts at saved show) + turn_off(): Turn off light + toggle(): Toggle light on/off + set_show(show): Set light to specific show + set_speed(speed): Set animation speed (1x-8x) + set_brightness(brightness): Set brightness (25%-100%) + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> pool_light = pool.lights["Pool Light"] + >>> + >>> # Check light capabilities + >>> print(f"Light model: {pool_light.model}") + >>> print(f"Available shows: {pool_light.effects}") + >>> + >>> # Check current state + >>> if pool_light.is_on: + ... print(f"Show: {pool_light.show}") + ... print(f"Speed: {pool_light.speed}") + ... print(f"Brightness: {pool_light.brightness}") + >>> + >>> # Control light + >>> await pool_light.turn_on() + >>> await pool_light.set_show(ColorLogicShow25.TROPICAL) + >>> await pool_light.set_speed(ColorLogicSpeed.TWO_TIMES) + >>> await pool_light.set_brightness(ColorLogicBrightness.SEVENTY_FIVE_PERCENT) + >>> await pool_light.turn_off() + + Important - Light State Transitions: + ColorLogic lights go through several transitional states when changing + settings. During these states, the light is NOT ready to accept commands: + + - FIFTEEN_SECONDS_WHITE: 15-second white period after power on + - CHANGING_SHOW: Actively cycling through shows + - POWERING_OFF: In process of turning off + - COOLDOWN: Cooling down after being turned off + + Always check is_ready before sending commands, or wait for state to + stabilize after each command. + + Note: + - Different light models support different show sets + - Non-ColorLogic lights (Pentair, Zodiac) have limited control + - Speed and brightness may not be adjustable on all models + - Some shows may require specific light hardware + - V2 protocol enables enhanced features on compatible lights + """ mspconfig: MSPColorLogicLight telemetry: TelemetryColorLogicLight diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py index 10633b6..b3e548f 100644 --- a/pyomnilogic_local/filter.py +++ b/pyomnilogic_local/filter.py @@ -7,7 +7,72 @@ class Filter(OmniEquipment[MSPFilter, TelemetryFilter]): - """Represents a filter in the OmniLogic system.""" + """Represents a pool/spa filtration pump in the OmniLogic system. + + A filter (also known as a filtration pump) is responsible for circulating and + filtering water through the pool or spa. Most filters support variable speed + operation with configurable presets for energy efficiency. + + The Filter class provides control over pump speed, monitoring of operational + state, and access to power consumption data. Filters can operate at: + - Preset speeds (LOW, MEDIUM, HIGH) configured in the system + - Custom speed percentages (0-100%) + - Variable RPM (for compatible pumps) + + Attributes: + mspconfig: Configuration data for this filter from MSP XML + telemetry: Real-time operational data and state + + Properties (Configuration): + equip_type: Equipment type identifier (e.g., FMT_VARIABLE_SPEED_PUMP) + max_percent: Maximum speed as percentage (0-100) + min_percent: Minimum speed as percentage (0-100) + max_rpm: Maximum speed in RPM + min_rpm: Minimum speed in RPM + priming_enabled: Whether priming mode is enabled + low_speed: Configured low speed preset value + medium_speed: Configured medium speed preset value + high_speed: Configured high speed preset value + + Properties (Telemetry): + state: Current operational state (OFF, ON, PRIMING, etc.) + speed: Current operating speed + valve_position: Current valve position + why_on: Reason code for pump being on + reported_speed: Speed reported by pump + power: Current power consumption in watts + last_speed: Previous speed setting + + Properties (Computed): + is_on: True if filter is currently running + is_ready: True if filter can accept commands + + Control Methods: + turn_on(): Turn on filter at last used speed + turn_off(): Turn off filter + run_preset_speed(speed): Run at LOW, MEDIUM, or HIGH preset + set_speed(speed): Run at specific percentage (0-100) + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> filter = pool.filters["Main Filter"] + >>> + >>> # Check current state + >>> print(f"Filter is {'on' if filter.is_on else 'off'}") + >>> print(f"Speed: {filter.speed}%, Power: {filter.power}W") + >>> + >>> # Control filter + >>> await filter.turn_on() # Turn on at last speed + >>> await filter.run_preset_speed(FilterSpeedPresets.LOW) + >>> await filter.set_speed(75) # Set to 75% + >>> await filter.turn_off() + + Note: + - Speed value of 0 will turn the filter off + - The API automatically validates against min_percent/max_percent + - Filter state may transition through PRIMING before reaching ON + - Not all filters support all speed ranges (check min/max values) + """ mspconfig: MSPFilter telemetry: TelemetryFilter diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py index 900e98b..e775886 100644 --- a/pyomnilogic_local/heater.py +++ b/pyomnilogic_local/heater.py @@ -14,12 +14,87 @@ class Heater(OmniEquipment[MSPVirtualHeater, TelemetryVirtualHeater]): - """ - Represents a heater in the OmniLogic system. - - Note: Temperature is always in Fahrenheit internally, so all temperature - properties and methods use Fahrenheit. Use the omni.system.units property to - determine if conversion to Celsius should be performed for display. + """Represents a heater system in the OmniLogic system. + + A heater maintains water temperature by heating pool or spa water to a + configured set point. The OmniLogic system supports various heater types: + - Gas heaters (natural gas or propane) + - Heat pumps (electric, energy efficient) + - Solar heaters (passive solar collection) + - Hybrid systems (combination of multiple heater types) + + The Heater class is actually a "virtual heater" that can manage one or more + physical heater equipment units. It provides temperature control, mode + selection, and monitoring of heater operation. + + Attributes: + mspconfig: Configuration data for this heater from MSP XML + telemetry: Real-time operational data and state + heater_equipment: Collection of physical heater units (HeaterEquipment) + + Properties (Configuration): + max_temp: Maximum settable temperature (Fahrenheit) + min_temp: Minimum settable temperature (Fahrenheit) + + Properties (Telemetry): + mode: Current heater mode (OFF, HEAT, AUTO, etc.) + current_set_point: Current target temperature (Fahrenheit) + solar_set_point: Solar heater target temperature (Fahrenheit) + enabled: Whether heater is enabled + silent_mode: Silent mode setting (reduced noise operation) + why_on: Reason code for heater being on + is_on: True if heater is enabled + + Control Methods: + turn_on(): Enable the heater + turn_off(): Disable the heater + set_temperature(temp): Set target temperature (Fahrenheit) + set_solar_temperature(temp): Set solar target temperature (Fahrenheit) + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> heater = pool.heater + >>> + >>> # Check current state + >>> print(f"Heater enabled: {heater.is_on}") + >>> print(f"Current set point: {heater.current_set_point}°F") + >>> print(f"Mode: {heater.mode}") + >>> + >>> # Control heater + >>> await heater.turn_on() + >>> await heater.set_temperature(85) # Set to 85°F + >>> + >>> # For systems with solar heaters + >>> if heater.solar_set_point > 0: + ... await heater.set_solar_temperature(90) + >>> + >>> await heater.turn_off() + >>> + >>> # Access physical heater equipment + >>> for equip in heater.heater_equipment: + ... print(f"Heater: {equip.name}, Type: {equip.equip_type}") + + Important - Temperature Units: + ALL temperature values in the OmniLogic API are in Fahrenheit, regardless + of the display units configured in the system. This is an internal API + requirement and cannot be changed. + + - All temperature properties return Fahrenheit values + - All temperature parameters must be provided in Fahrenheit + - Use system.units to determine display preference (not API units) + - If your application uses Celsius, convert before calling these methods + + Example: + >>> # If working in Celsius + >>> celsius_target = 29 + >>> fahrenheit_target = (celsius_target * 9/5) + 32 + >>> await heater.set_temperature(int(fahrenheit_target)) + + Note: + - Temperature range is enforced (min_temp to max_temp) + - Multiple physical heaters may be grouped under one virtual heater + - Solar heaters have separate set points from gas/heat pump heaters + - Heater may not turn on immediately if water temp is already at set point """ mspconfig: MSPVirtualHeater diff --git a/pyomnilogic_local/heater_equip.py b/pyomnilogic_local/heater_equip.py index 66bca3e..ff46435 100644 --- a/pyomnilogic_local/heater_equip.py +++ b/pyomnilogic_local/heater_equip.py @@ -10,14 +10,73 @@ class HeaterEquipment(OmniEquipment[MSPHeaterEquip, TelemetryHeater]): - """ - Represents a heater equipment in the OmniLogic system. - - This is the physical heater equipment (gas, heat pump, solar, etc.) that is - controlled by a VirtualHeater. A VirtualHeater can have one or more HeaterEquipment - instances associated with it. - - Note: Temperature is always in Fahrenheit internally. + """Represents physical heater equipment in the OmniLogic system. + + HeaterEquipment represents an individual physical heating device (gas heater, + heat pump, solar panel system, etc.). It is controlled by a parent VirtualHeater + which can manage one or more physical heater units. + + The OmniLogic system uses a virtual/physical heater architecture: + - VirtualHeater: User-facing heater control (turn_on, set_temperature, etc.) + - HeaterEquipment: Individual physical heating devices managed by the virtual heater + + This architecture allows the system to coordinate multiple heating sources + (e.g., solar + gas backup) under a single virtual heater interface. + + Heater Types: + - GAS: Natural gas or propane heater (fast heating) + - HEAT_PUMP: Electric heat pump (energy efficient) + - SOLAR: Solar heating panels (free but weather-dependent) + - HYBRID: Combination systems + + Attributes: + mspconfig: Configuration data for this physical heater + telemetry: Real-time operational state + + Properties (Configuration): + heater_type: Type of heating unit (GAS, HEAT_PUMP, SOLAR) + min_filter_speed: Minimum filter speed required for operation + sensor_id: System ID of the temperature sensor + supports_cooling: Whether this unit can cool (heat pumps only) + + Properties (Telemetry): + state: Current heater state (OFF, ON, PAUSE) + current_temp: Temperature reading from associated sensor (Fahrenheit) + enabled: Whether heater is enabled + priority: Heater priority for multi-heater systems + maintain_for: Time to maintain current operation + is_on: True if heater is currently running + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> heater = pool.heater + >>> + >>> # Access physical heater equipment + >>> for equip in heater.heater_equipment: + ... print(f"Heater: {equip.name}") + ... print(f"Type: {equip.heater_type}") + ... print(f"State: {equip.state}") + ... print(f"Current temp: {equip.current_temp}°F") + ... print(f"Is on: {equip.is_on}") + ... print(f"Min filter speed: {equip.min_filter_speed}%") + >>> + >>> # Check for cooling support (heat pumps) + >>> gas_heater = heater.heater_equipment["Gas Heater"] + >>> if gas_heater.supports_cooling: + ... print("This unit can cool as well as heat") + + Important - Temperature Units: + ALL temperature values are in Fahrenheit, regardless of system display + settings. The system.units property only affects user interface display, + not internal API values. + + Note: + - HeaterEquipment is read-only (no direct control methods) + - Control heaters through the parent VirtualHeater instance + - Multiple heater equipment can work together (e.g., solar + gas) + - Priority determines which heater runs first in multi-heater systems + - Minimum filter speed must be met for safe heater operation + - State transitions: OFF → ON → PAUSE (when conditions not met) """ mspconfig: MSPHeaterEquip diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index f404d84..fa03939 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -7,7 +7,71 @@ class Pump(OmniEquipment[MSPPump, TelemetryPump]): - """Represents a pump in the OmniLogic system.""" + """Represents a pump in the OmniLogic system. + + Pumps are used for various functions including water circulation, waterfalls, + water features, spillover, and other hydraulic functions. Pumps can be + single-speed, multi-speed, or variable speed depending on the model. + + The Pump class provides control over pump speed and operation, with support + for preset speeds and custom speed percentages for variable speed pumps. + + Attributes: + mspconfig: Configuration data for this pump from MSP XML + telemetry: Real-time operational data and state + + Properties (Configuration): + equip_type: Equipment type (e.g., PMP_VARIABLE_SPEED_PUMP) + function: Pump function (e.g., PMP_PUMP, PMP_WATER_FEATURE, PMP_SPILLOVER) + max_percent: Maximum speed as percentage (0-100) + min_percent: Minimum speed as percentage (0-100) + max_rpm: Maximum speed in RPM + min_rpm: Minimum speed in RPM + priming_enabled: Whether priming mode is enabled + low_speed: Configured low speed preset value + medium_speed: Configured medium speed preset value + high_speed: Configured high speed preset value + + Properties (Telemetry): + state: Current operational state (OFF, ON) + speed: Current operating speed + last_speed: Previous speed setting + why_on: Reason code for pump being on + + Properties (Computed): + is_on: True if pump is currently running + is_ready: True if pump can accept commands + + Control Methods: + turn_on(): Turn on pump at last used speed + turn_off(): Turn off pump + run_preset_speed(speed): Run at LOW, MEDIUM, or HIGH preset + set_speed(speed): Run at specific percentage (0-100) + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> pump = pool.pumps["Waterfall Pump"] + >>> + >>> # Check current state + >>> if pump.is_on: + ... print(f"Pump is running at {pump.speed}%") + >>> + >>> # Control pump + >>> await pump.turn_on() # Turn on at last speed + >>> await pump.run_preset_speed(PumpSpeedPresets.MEDIUM) + >>> await pump.set_speed(60) # Set to 60% + >>> await pump.turn_off() + >>> + >>> # Check pump function + >>> if pump.function == "PMP_WATER_FEATURE": + ... print("This is a water feature pump") + + Note: + - Speed value of 0 will turn the pump off + - The API automatically validates against min_percent/max_percent + - Not all pumps support variable speed operation + - Pump function determines its purpose (circulation, feature, spillover, etc.) + """ mspconfig: MSPPump telemetry: TelemetryPump diff --git a/pyomnilogic_local/relay.py b/pyomnilogic_local/relay.py index c769b98..c793e96 100644 --- a/pyomnilogic_local/relay.py +++ b/pyomnilogic_local/relay.py @@ -12,7 +12,61 @@ class Relay(OmniEquipment[MSPRelay, TelemetryRelay]): - """Represents a relay in the OmniLogic system.""" + """Represents a relay in the OmniLogic system. + + Relays are ON/OFF switches that control various pool and spa equipment that + doesn't require variable speed control. Common relay applications include: + - Pool/spa lights (non-ColorLogic) + - Water features and fountains + - Deck jets and bubblers + - Auxiliary equipment (blowers, misters, etc.) + - Landscape lighting + - Accessory equipment + + Each relay has a configured function that determines its purpose and behavior. + Relays can be controlled manually or automatically based on schedules and + other system conditions. + + Attributes: + mspconfig: Configuration data for this relay from MSP XML + telemetry: Real-time state and status data + + Properties: + relay_type: Type of relay (e.g., VALVE_ACTUATOR, HIGH_VOLTAGE_RELAY, LOW_VOLTAGE_RELAY) + function: Relay function (e.g., WATER_FEATURE, CLEANER, etc) + state: Current state (ON or OFF) + why_on: Reason code for relay being on (manual, schedule, etc.) + is_on: True if relay is currently energized + + Control Methods: + turn_on(): Energize the relay (turn equipment on) + turn_off(): De-energize the relay (turn equipment off) + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> deck_jets = pool.relays["Deck Jets"] + >>> + >>> # Check current state + >>> if deck_jets.is_on: + ... print("Deck jets are currently running") + >>> + >>> # Control relay + >>> await deck_jets.turn_on() + >>> await deck_jets.turn_off() + >>> + >>> # Check function + >>> print(f"Relay function: {deck_jets.function}") + >>> print(f"Relay type: {deck_jets.relay_type}") + >>> + >>> # Check why the relay is on + >>> if deck_jets.is_on: + ... print(f"Why on: {deck_jets.why_on}") + + Note: + - Relays are binary ON/OFF devices (no speed or intensity control) + - The why_on property indicates if control is manual or automatic + - Relay state changes are immediate (no priming or delay states) + """ mspconfig: MSPRelay telemetry: TelemetryRelay diff --git a/pyomnilogic_local/sensor.py b/pyomnilogic_local/sensor.py index eaa9325..b747033 100644 --- a/pyomnilogic_local/sensor.py +++ b/pyomnilogic_local/sensor.py @@ -10,11 +10,72 @@ class Sensor(OmniEquipment[MSPSensor, None]): - """ - Represents a sensor in the OmniLogic system. + """Represents a sensor in the OmniLogic system. + + Sensors are monitoring devices that measure various environmental and system + parameters. Unlike other equipment, sensors do not have their own telemetry + data structure - instead, they contribute readings to the telemetry of other + equipment (Backyard, BoW, Heater, etc.). + + Sensor Types: + - AIR_TEMP: Measures ambient air temperature + - SOLAR_TEMP: Measures solar collector temperature + - WATER_TEMP: Measures water temperature in pool/spa + - FLOW: Detects water flow (binary on/off) + - ORP: Measures Oxidation-Reduction Potential (chlorine effectiveness) + - EXT_INPUT: External input sensor (various purposes) + + Sensors are read-only monitoring devices with no control methods. + Their readings appear in the telemetry of associated equipment: + - Air temperature → Backyard telemetry + - Water temperature → BoW (Body of Water) telemetry + - Solar temperature → Heater telemetry + - Flow → BoW telemetry + - ORP → CSAD telemetry + + Attributes: + mspconfig: Configuration data for this sensor from MSP XML + telemetry: Always None (sensors don't have their own telemetry) + + Properties: + sensor_type: Type of sensor (AIR_TEMP, WATER_TEMP, FLOW, etc.) + units: Units of measurement (FAHRENHEIT, CELSIUS, MILLIVOLTS, etc.) + name: Sensor name from configuration + system_id: Unique system identifier + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> sensors = pool.sensors + >>> + >>> # Iterate through sensors + >>> for sensor in sensors: + ... print(f"{sensor.name}: {sensor.sensor_type} ({sensor.units})") + >>> + >>> # Get readings from parent equipment telemetry + >>> # Water temp sensor → BoW telemetry + >>> water_temp = pool.water_temp + >>> + >>> # Air temp sensor → Backyard telemetry + >>> air_temp = omni.backyard.air_temp + >>> + >>> # Flow sensor → BoW telemetry + >>> has_flow = pool.flow > 0 + + Important: + Sensors do NOT have their own telemetry or state. To get sensor readings, + access the telemetry of the parent equipment: + + - For water temperature: Use bow.water_temp + - For air temperature: Use backyard.air_temp + - For flow: Use bow.flow + - For ORP: Use csad.current_orp - Note: Sensors don't have their own telemetry - they contribute data to - other equipment (like BoW, Backyard, Heaters, etc.) + Note: + - Sensors are passive monitoring devices (no control methods) + - Sensor readings update as part of parent equipment telemetry refresh + - Temperature sensors may use Fahrenheit or Celsius (check units property) + - Flow sensors typically return 255 for flow, 0 for no flow + - ORP sensors measure in millivolts (typically 400-800 mV) """ mspconfig: MSPSensor From f787f94b87fa6a4fe817e1fe0e19edc8c9fc5e90 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 1 Nov 2025 17:29:05 -0500 Subject: [PATCH 28/61] feat: add some nicer repr functions to equipment classes --- pyomnilogic_local/_base.py | 16 ++++++++++++++++ pyomnilogic_local/bow.py | 25 +++++++++++++++++++++++++ pyomnilogic_local/omnilogic.py | 21 +++++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py index c2d3fe2..3f2ed5a 100644 --- a/pyomnilogic_local/_base.py +++ b/pyomnilogic_local/_base.py @@ -149,3 +149,19 @@ def update_telemetry(self, telemetry: Telemetry) -> None: self.telemetry = cast(TelemetryT, specific_telemetry) else: self.telemetry = cast(TelemetryT, None) + + def __repr__(self) -> str: + """Return a string representation of the equipment for debugging. + + Returns: + A string showing the class name, system_id, name, and state (if available). + """ + class_name = self.__class__.__name__ + parts = [f"system_id={self.system_id!r}", f"name={self.name!r}"] + + # Include state if the equipment has telemetry with a state attribute + if hasattr(self, "telemetry") and self.telemetry is not None: + if (state := getattr(self.telemetry, "state", None)) is not None: + parts.append(f"state={state!r}") + + return f"{class_name}({', '.join(parts)})" diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index 8d0481d..4664854 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -138,6 +138,31 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): def __init__(self, omni: "OmniLogic", mspconfig: MSPBoW, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) + def __repr__(self) -> str: + """Return a string representation of the Bow for debugging. + + Returns: + A string showing the class name, system_id, name, type, and equipment counts. + """ + parts = [f"system_id={self.system_id!r}", f"name={self.name!r}", f"type={self.equip_type!r}"] + + # Add equipment counts + parts.append(f"filters={len(self.filters)}") + parts.append(f"pumps={len(self.pumps)}") + parts.append(f"lights={len(self.lights)}") + parts.append(f"relays={len(self.relays)}") + parts.append(f"sensors={len(self.sensors)}") + + # Add heater and chlorinator status (present or not) + if self.heater is not None: + parts.append("heater=True") + if self.chlorinator is not None: + parts.append("chlorinator=True") + if len(self.csads) > 0: + parts.append(f"csads={len(self.csads)}") + + return f"Bow({', '.join(parts)})" + @property def equip_type(self) -> BodyOfWaterType | str: """The equipment type of the bow (POOL or SPA).""" diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index 3e51d04..a16c3ff 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -42,6 +42,27 @@ def __init__(self, host: str, port: int = 10444) -> None: self._api = OmniLogicAPI(host, port) self._refresh_lock = asyncio.Lock() + def __repr__(self) -> str: + """Return a string representation of the OmniLogic instance for debugging. + + Returns: + A string showing host, port, and counts of various equipment types. + """ + # Only show equipment counts if backyard has been initialized + if hasattr(self, "backyard"): + bow_count = len(self.backyard.bow) + light_count = len(self.all_lights) + relay_count = len(self.all_relays) + pump_count = len(self.all_pumps) + filter_count = len(self.all_filters) + + return ( + f"OmniLogic(host={self.host!r}, port={self.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)" + async def refresh( self, *, From 2a4de7c2ce1c3f78b20dfecd62ce4e9fd653ac95 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 1 Nov 2025 17:36:58 -0500 Subject: [PATCH 29/61] docs: cleanup a docstring --- pyomnilogic_local/colorlogiclight.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index ed81040..7852b5d 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -31,10 +31,10 @@ class ColorLogicLight(OmniEquipment[MSPColorLogicLight, TelemetryColorLogicLight multiple light models with different capabilities: Light Models: - - ColorLogic 2.5: Classic model with 10 preset shows - - ColorLogic 4.0: Enhanced model with 15 preset shows - - ColorLogic UCL (UniversaLogic): Universal controller for various lights - - ColorLogic SAM: Smart lighting system + - ColorLogic 2.5 + - ColorLogic 4.0 + - ColorLogic UCL + - ColorLogic SAM - Pentair and Zodiac compatible lights (limited functionality) Each light model supports different shows, speeds, and brightness levels. From cb4ecaf8655157b6896f095d5ac953c7e92817b6 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:07:56 -0600 Subject: [PATCH 30/61] feat: add mspconfig data for chlorinator-equipment --- pyomnilogic_local/chlorinator.py | 30 ++++++++++++++++++++++++++- pyomnilogic_local/models/mspconfig.py | 10 ++++----- pyomnilogic_local/omnilogic.py | 11 ++++++++++ pyomnilogic_local/omnitypes.py | 6 ++++++ 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py index 6a870b0..a98c8a4 100644 --- a/pyomnilogic_local/chlorinator.py +++ b/pyomnilogic_local/chlorinator.py @@ -1,7 +1,11 @@ +from typing import TYPE_CHECKING + from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.chlorinator_equip import ChlorinatorEquipment +from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPChlorinator -from pyomnilogic_local.models.telemetry import TelemetryChlorinator +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryChlorinator from pyomnilogic_local.omnitypes import ( ChlorinatorCellType, ChlorinatorOperatingMode, @@ -9,6 +13,9 @@ ) from pyomnilogic_local.util import OmniEquipmentNotInitializedError +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): """Represents a chlorinator in the OmniLogic system. @@ -21,6 +28,7 @@ class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): Attributes: mspconfig: The MSP configuration for this chlorinator telemetry: Real-time telemetry data for this chlorinator + chlorinator_equipment: Collection of physical chlorinator equipment units Example: >>> chlorinator = pool.get_chlorinator() @@ -32,6 +40,26 @@ class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): mspconfig: MSPChlorinator telemetry: TelemetryChlorinator + chlorinator_equipment: EquipmentDict[ChlorinatorEquipment] = EquipmentDict() + + def __init__(self, omni: "OmniLogic", mspconfig: MSPChlorinator, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + def _update_equipment(self, mspconfig: MSPChlorinator, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + if telemetry is None: + return + self._update_chlorinator_equipment(mspconfig, telemetry) + + def _update_chlorinator_equipment(self, mspconfig: MSPChlorinator, telemetry: Telemetry) -> None: + """Update the chlorinator equipment based on the MSP configuration.""" + if mspconfig.chlorinator_equipment is None: + self.chlorinator_equipment = EquipmentDict() + return + + self.chlorinator_equipment = EquipmentDict( + [ChlorinatorEquipment(self._omni, equip, telemetry) for equip in mspconfig.chlorinator_equipment] + ) # Expose MSPConfig attributes @property diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index c9901bc..232d979 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -22,6 +22,7 @@ BodyOfWaterType, ChlorinatorCellType, ChlorinatorDispenserType, + ChlorinatorType, ColorLogicLightType, ColorLogicShow25, ColorLogicShow40, @@ -202,6 +203,8 @@ class MSPChlorinatorEquip(OmniBase): omni_type: OmniType = OmniType.CHLORINATOR_EQUIP + equip_type: Literal["PET_CHLORINATOR"] = Field(alias="Type") + chlorinator_type: ChlorinatorType = Field(alias="Chlorinator-Type") enabled: bool = Field(alias="Enabled") @@ -240,10 +243,8 @@ def __init__(self, **data: Any) -> None: # The chlorinator equipment are nested down inside a list of "Operations", which also includes non Chlorinator-Equipment items. # We need to first filter down to just the chlorinator equipment items, then populate our self.chlorinator_equipment with parsed # versions of those items. - chlorinator_equip_data = [op for op in data.get("Operation", {}) if OmniType.CHLORINATOR_EQUIP in op][0] - self.chlorinator_equipment = [ - MSPChlorinatorEquip.model_validate(equip) for equip in chlorinator_equip_data[OmniType.CHLORINATOR_EQUIP] - ] + chlorinator_equip_data = [op[OmniType.CHLORINATOR_EQUIP] for op in data.get("Operation", {}) if OmniType.CHLORINATOR_EQUIP in op] + self.chlorinator_equipment = [MSPChlorinatorEquip.model_validate(equip) for equip in chlorinator_equip_data] class MSPCSAD(OmniBase): @@ -374,7 +375,6 @@ def load_xml(xml: str) -> MSPConfig: # everything that *could* be a list into a list to make the parsing more consistent. force_list=( OmniType.BOW_MSP, - OmniType.CHLORINATOR_EQUIP, OmniType.CSAD, OmniType.CL_LIGHT, OmniType.FAVORITES, diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index a16c3ff..b6faa2b 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -7,6 +7,7 @@ from pyomnilogic_local.api import OmniLogicAPI from pyomnilogic_local.backyard import Backyard from pyomnilogic_local.chlorinator import Chlorinator +from pyomnilogic_local.chlorinator_equip import ChlorinatorEquipment from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.colorlogiclight import ColorLogicLight from pyomnilogic_local.csad import CSAD @@ -211,6 +212,14 @@ def all_chlorinators(self) -> EquipmentDict[Chlorinator]: chlorinators.append(bow.chlorinator) return EquipmentDict(chlorinators) + @property + def all_chlorinator_equipment(self) -> EquipmentDict[ChlorinatorEquipment]: + """Returns all ChlorinatorEquipment instances across all chlorinators in the backyard.""" + chlorinator_equipment: list[ChlorinatorEquipment] = [] + for chlorinator in self.all_chlorinators.values(): + chlorinator_equipment.extend(chlorinator.chlorinator_equipment.values()) + return EquipmentDict(chlorinator_equipment) + @property def all_csads(self) -> EquipmentDict[CSAD]: """Returns all CSAD instances across all bows in the backyard.""" @@ -240,6 +249,7 @@ def get_equipment_by_name(self, name: str) -> OmniEquipment[Any, Any] | None: all_equipment.extend(self.all_heaters.values()) all_equipment.extend(self.all_heater_equipment.values()) all_equipment.extend(self.all_chlorinators.values()) + all_equipment.extend(self.all_chlorinator_equipment.values()) all_equipment.extend(self.all_csads.values()) for equipment in all_equipment: @@ -268,6 +278,7 @@ def get_equipment_by_id(self, system_id: int) -> OmniEquipment[Any, Any] | None: all_equipment.extend(self.all_heaters.values()) all_equipment.extend(self.all_heater_equipment.values()) all_equipment.extend(self.all_chlorinators.values()) + all_equipment.extend(self.all_chlorinator_equipment.values()) all_equipment.extend(self.all_csads.values()) for equipment in all_equipment: diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index e54751c..a6787c2 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -149,6 +149,12 @@ class ChlorinatorOperatingMode(IntEnum, PrettyEnum): ORP_TIMED_RW = 3 # CSAD in ORP mode experienced condition that prevents ORP operation +class ChlorinatorType(StrEnum, PrettyEnum): + MAIN_PANEL = "CHLOR_TYPE_MAIN_PANEL" + DISPENSER = "CHLOR_TYPE_DISPENSER" + AQUA_RITE = "CHLOR_TYPE_AQUA_RITE" + + class ChlorinatorDispenserType(StrEnum, PrettyEnum): SALT = "SALT_DISPENSING" LIQUID = "LIQUID_DISPENSING" From 0e8237fa5e77b105ec62e9da71ee5af27b462773 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:26:20 -0600 Subject: [PATCH 31/61] fix: add missing chlorinator_equip.py --- pyomnilogic_local/chlorinator_equip.py | 80 ++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 pyomnilogic_local/chlorinator_equip.py diff --git a/pyomnilogic_local/chlorinator_equip.py b/pyomnilogic_local/chlorinator_equip.py new file mode 100644 index 0000000..fe4bbef --- /dev/null +++ b/pyomnilogic_local/chlorinator_equip.py @@ -0,0 +1,80 @@ +from typing import TYPE_CHECKING, Literal + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPChlorinatorEquip +from pyomnilogic_local.models.telemetry import Telemetry +from pyomnilogic_local.omnitypes import ChlorinatorType + +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + + +class ChlorinatorEquipment(OmniEquipment[MSPChlorinatorEquip, None]): + """Represents physical chlorinator equipment in the OmniLogic system. + + ChlorinatorEquipment represents an individual physical chlorinator device + (salt cell, liquid dispenser, tablet feeder, etc.). It is controlled by a + parent Chlorinator which manages one or more physical chlorinator units. + + The OmniLogic system uses a parent/child chlorinator architecture: + - Chlorinator: User-facing chlorinator control (turn_on, set_timed_percent, etc.) + - ChlorinatorEquipment: Individual physical chlorination devices managed by the parent + + This architecture allows the system to coordinate multiple chlorination sources + under a single chlorinator interface. + + Chlorinator Equipment Types: + - MAIN_PANEL: Main panel chlorinator + - DISPENSER: Chemical dispenser + - AQUA_RITE: AquaRite chlorinator system + + Note: Unlike heater equipment, chlorinator equipment does not have separate + telemetry entries. All telemetry is reported through the parent Chlorinator. + + Attributes: + mspconfig: Configuration data for this physical chlorinator equipment + + Properties (Configuration): + equip_type: Equipment type (always "PET_CHLORINATOR") + chlorinator_type: Type of chlorinator (MAIN_PANEL, DISPENSER, AQUA_RITE) + enabled: Whether this chlorinator equipment is enabled + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> chlorinator = pool.chlorinator + >>> + >>> # Access physical chlorinator equipment + >>> for equip in chlorinator.chlorinator_equipment: + ... print(f"Chlorinator Equipment: {equip.name}") + ... print(f"Type: {equip.chlorinator_type}") + ... print(f"Enabled: {equip.enabled}") + ... print(f"System ID: {equip.system_id}") + + Important Notes: + - ChlorinatorEquipment is read-only (no direct control methods) + - Control chlorinator equipment through the parent Chlorinator instance + - Multiple chlorinator equipment can work together + - Telemetry is accessed through the parent Chlorinator, not individual equipment + - Equipment may be disabled but still configured in the system + """ + + mspconfig: MSPChlorinatorEquip + telemetry: None + + def __init__(self, omni: "OmniLogic", mspconfig: MSPChlorinatorEquip, telemetry: Telemetry | None) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def equip_type(self) -> Literal["PET_CHLORINATOR"]: + """Returns the equipment type (always 'PET_CHLORINATOR').""" + return self.mspconfig.equip_type + + @property + def chlorinator_type(self) -> ChlorinatorType: + """Returns the type of chlorinator (MAIN_PANEL, DISPENSER, or AQUA_RITE).""" + return self.mspconfig.chlorinator_type + + @property + def enabled(self) -> bool: + """Returns whether the chlorinator equipment is enabled in configuration.""" + return self.mspconfig.enabled From f748c54555ce60c67092b34bd987b7e341804a99 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:54:24 -0600 Subject: [PATCH 32/61] feat: add csad-equipment support --- pyomnilogic_local/csad.py | 28 +++++++++- pyomnilogic_local/csad_equip.py | 75 +++++++++++++++++++++++++++ pyomnilogic_local/heater_equip.py | 2 +- pyomnilogic_local/models/mspconfig.py | 37 +++++++++++++ pyomnilogic_local/omnilogic.py | 11 ++++ pyomnilogic_local/omnitypes.py | 5 ++ 6 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 pyomnilogic_local/csad_equip.py diff --git a/pyomnilogic_local/csad.py b/pyomnilogic_local/csad.py index 8eee1e2..0d74b97 100644 --- a/pyomnilogic_local/csad.py +++ b/pyomnilogic_local/csad.py @@ -1,8 +1,15 @@ +from typing import TYPE_CHECKING + from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.collections import EquipmentDict +from pyomnilogic_local.csad_equip import CSADEquipment from pyomnilogic_local.models.mspconfig import MSPCSAD -from pyomnilogic_local.models.telemetry import TelemetryCSAD +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryCSAD from pyomnilogic_local.omnitypes import CSADMode, CSADStatus, CSADType +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]): """Represents a CSAD (Chemistry Sense and Dispense) system in the OmniLogic system. @@ -20,6 +27,7 @@ class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]): Attributes: mspconfig: The MSP configuration for this CSAD telemetry: Real-time telemetry data for this CSAD + csad_equipment: Collection of physical CSAD equipment devices Example: >>> csad = pool.get_csad() @@ -31,6 +39,24 @@ class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]): mspconfig: MSPCSAD telemetry: TelemetryCSAD + csad_equipment: EquipmentDict[CSADEquipment] = EquipmentDict() + + def __init__(self, omni: "OmniLogic", mspconfig: MSPCSAD, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + def _update_equipment(self, mspconfig: MSPCSAD, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + if telemetry is None: + return + self._update_csad_equipment(mspconfig, telemetry) + + def _update_csad_equipment(self, mspconfig: MSPCSAD, telemetry: Telemetry) -> None: + """Update the CSAD equipment based on the MSP configuration.""" + if mspconfig.csad_equipment is None: + self.csad_equipment = EquipmentDict() + return + + self.csad_equipment = EquipmentDict([CSADEquipment(self._omni, equip, telemetry) for equip in mspconfig.csad_equipment]) # Expose MSPConfig attributes @property diff --git a/pyomnilogic_local/csad_equip.py b/pyomnilogic_local/csad_equip.py new file mode 100644 index 0000000..64022d9 --- /dev/null +++ b/pyomnilogic_local/csad_equip.py @@ -0,0 +1,75 @@ +"""CSAD equipment classes for Omnilogic.""" + +from typing import TYPE_CHECKING, Literal + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPCSADEquip +from pyomnilogic_local.models.telemetry import Telemetry +from pyomnilogic_local.omnitypes import CSADEquipmentType + +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + + +class CSADEquipment(OmniEquipment[MSPCSADEquip, None]): + """Represents a CSAD (chemical automation) equipment device. + + CSADEquipment represents an individual physical CSAD device (e.g., AQL-CHEM). + It is controlled by a parent CSAD which manages one or more physical CSAD units. + + The OmniLogic system uses a parent/child CSAD architecture: + - CSAD: User-facing CSAD control (monitoring, dispensing) + - CSADEquipment: Individual physical CSAD devices managed by the parent + + CSAD Equipment Types: + - AQL_CHEM: AquaLink Chemistry System + + Note: Like chlorinator equipment, CSAD equipment does not have separate + telemetry entries. All telemetry is reported through the parent CSAD. + + Attributes: + mspconfig: Configuration data for this physical CSAD equipment + + Properties (Configuration): + equip_type: Equipment type (always "PET_CSAD") + csad_type: Type of CSAD equipment (e.g., AQL_CHEM) + enabled: Whether this CSAD equipment is enabled + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> csad = pool.get_csad() + >>> + >>> # Access physical CSAD equipment + >>> for equip in csad.csad_equipment: + ... print(f"CSAD Equipment: {equip.name}") + ... print(f"Type: {equip.csad_type}") + ... print(f"Enabled: {equip.enabled}") + ... print(f"System ID: {equip.system_id}") + + Important Notes: + - CSADEquipment is read-only (no direct control methods) + - Control CSAD equipment through the parent CSAD instance + - Telemetry is accessed through the parent CSAD, not individual equipment + - Equipment may be disabled but still configured in the system + """ + + mspconfig: MSPCSADEquip + telemetry: None + + def __init__(self, omni: "OmniLogic", mspconfig: MSPCSADEquip, telemetry: Telemetry | None) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def equip_type(self) -> Literal["PET_CSAD"]: + """Returns the equipment type (always 'PET_CSAD').""" + return self.mspconfig.equip_type + + @property + def csad_type(self) -> CSADEquipmentType | str: + """Returns the type of CSAD equipment (e.g., AQL_CHEM).""" + return self.mspconfig.csad_type + + @property + def enabled(self) -> bool: + """Returns whether the CSAD equipment is enabled in configuration.""" + return self.mspconfig.enabled diff --git a/pyomnilogic_local/heater_equip.py b/pyomnilogic_local/heater_equip.py index ff46435..da944ac 100644 --- a/pyomnilogic_local/heater_equip.py +++ b/pyomnilogic_local/heater_equip.py @@ -37,7 +37,7 @@ class HeaterEquipment(OmniEquipment[MSPHeaterEquip, TelemetryHeater]): heater_type: Type of heating unit (GAS, HEAT_PUMP, SOLAR) min_filter_speed: Minimum filter speed required for operation sensor_id: System ID of the temperature sensor - supports_cooling: Whether this unit can cool (heat pumps only) + supports_cooling: Whether this unit can cool Properties (Telemetry): state: Current heater state (OFF, ON, PAUSE) diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 232d979..25682a1 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -28,6 +28,7 @@ ColorLogicShow40, ColorLogicShowUCL, ColorLogicShowUCLV2, + CSADEquipmentType, CSADType, FilterType, HeaterType, @@ -247,7 +248,18 @@ def __init__(self, **data: Any) -> None: self.chlorinator_equipment = [MSPChlorinatorEquip.model_validate(equip) for equip in chlorinator_equip_data] +class MSPCSADEquip(OmniBase): + _YES_NO_FIELDS = {"enabled"} + + omni_type: OmniType = OmniType.CSAD_EQUIP + + equip_type: Literal["PET_CSAD"] = Field(alias="Type") + csad_type: CSADEquipmentType | str = Field(alias="CSAD-Type") + enabled: bool = Field(alias="Enabled") + + class MSPCSAD(OmniBase): + _sub_devices = {"csad_equipment"} _YES_NO_FIELDS = {"enabled"} omni_type: OmniType = OmniType.CSAD @@ -264,6 +276,30 @@ class MSPCSAD(OmniBase): orp_high_alarm_level: int = Field(alias="ORP-High-Alarm-Level") orp_forced_on_time: int = Field(alias="ORP-Forced-On-Time") orp_forced_enabled: bool = Field(alias="ORP-Forced-Enabled") + csad_equipment: list[MSPCSADEquip] | None = None + + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + # The CSAD equipment are nested down inside a list of "Operations", which also includes non CSAD-Equipment items. + # We need to first filter down to just the CSAD equipment items, then populate our self.csad_equipment with parsed + # versions of those items. + csad_equip_data = [op[OmniType.CSAD_EQUIP] for op in data.get("Operation", {}) if OmniType.CSAD_EQUIP in op] + self.csad_equipment = [MSPCSADEquip.model_validate(equip) for equip in csad_equip_data] + + # The CSAD equipment are nested down inside a list of "Operations", which also includes non CSAD-Equipment items. + # We need to first filter down to just the CSAD equipment items, then populate our self.csad_equipment with parsed + # versions of those items. + # operations_list = data.get("Operation", []) + # if operations_list: + # csad_equip_ops = [op for op in operations_list if OmniType.CSAD_EQUIP in op] + # if csad_equip_ops: + # csad_equip_data = csad_equip_ops[0][OmniType.CSAD_EQUIP] + # self.csad_equipment = [MSPCSADEquip.model_validate(equip) for equip in csad_equip_data] + # else: + # self.csad_equipment = [] + # else: + # self.csad_equipment = [] class MSPColorLogicLight(OmniBase): @@ -355,6 +391,7 @@ class MSPSchedule(OmniBase): | MSPChlorinator | MSPChlorinatorEquip | MSPCSAD + | MSPCSADEquip | MSPColorLogicLight ) diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index b6faa2b..f8eae5d 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -11,6 +11,7 @@ from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.colorlogiclight import ColorLogicLight from pyomnilogic_local.csad import CSAD +from pyomnilogic_local.csad_equip import CSADEquipment from pyomnilogic_local.filter import Filter from pyomnilogic_local.heater import Heater from pyomnilogic_local.heater_equip import HeaterEquipment @@ -220,6 +221,14 @@ def all_chlorinator_equipment(self) -> EquipmentDict[ChlorinatorEquipment]: chlorinator_equipment.extend(chlorinator.chlorinator_equipment.values()) return EquipmentDict(chlorinator_equipment) + @property + def all_csad_equipment(self) -> EquipmentDict[CSADEquipment]: + """Returns all CSADEquipment instances across all CSADs in the backyard.""" + csad_equipment: list[CSADEquipment] = [] + for csad in self.all_csads.values(): + csad_equipment.extend(csad.csad_equipment.values()) + return EquipmentDict(csad_equipment) + @property def all_csads(self) -> EquipmentDict[CSAD]: """Returns all CSAD instances across all bows in the backyard.""" @@ -251,6 +260,7 @@ def get_equipment_by_name(self, name: str) -> OmniEquipment[Any, Any] | None: all_equipment.extend(self.all_chlorinators.values()) all_equipment.extend(self.all_chlorinator_equipment.values()) all_equipment.extend(self.all_csads.values()) + all_equipment.extend(self.all_csad_equipment.values()) for equipment in all_equipment: if equipment.name == name: @@ -280,6 +290,7 @@ def get_equipment_by_id(self, system_id: int) -> OmniEquipment[Any, Any] | None: all_equipment.extend(self.all_chlorinators.values()) all_equipment.extend(self.all_chlorinator_equipment.values()) all_equipment.extend(self.all_csads.values()) + all_equipment.extend(self.all_csad_equipment.values()) for equipment in all_equipment: if equipment.system_id == system_id: diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index a6787c2..974bcaa 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -45,6 +45,7 @@ class OmniType(StrEnum): CHLORINATOR = "Chlorinator" CHLORINATOR_EQUIP = "Chlorinator-Equipment" CSAD = "CSAD" + CSAD_EQUIP = "CSAD-Equipment" CL_LIGHT = "ColorLogic-Light" FAVORITES = "Favorites" FILTER = "Filter" @@ -338,6 +339,10 @@ class CSADType(StrEnum, PrettyEnum): CO2 = "CO2" +class CSADEquipmentType(StrEnum, PrettyEnum): + AQL_CHEM = "AQL-CHEM" + + # Chemistry Sense and Dispense class CSADStatus(IntEnum, PrettyEnum): NOT_DISPENSING = 0 From d9ced125aa3e67aaffc5fa0589a482c0c57d08d2 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:22:30 -0600 Subject: [PATCH 33/61] feat: add EffectsCollection for easier control of light effects --- pyomnilogic_local/__init__.py | 3 + pyomnilogic_local/collections.py | 168 +++++++++++++++++++++++--- pyomnilogic_local/colorlogiclight.py | 47 ++++++- pyomnilogic_local/models/mspconfig.py | 14 --- tests/test_effects_collection.py | 158 ++++++++++++++++++++++++ 5 files changed, 353 insertions(+), 37 deletions(-) create mode 100644 tests/test_effects_collection.py diff --git a/pyomnilogic_local/__init__.py b/pyomnilogic_local/__init__.py index 5aeb140..8c2f6b5 100644 --- a/pyomnilogic_local/__init__.py +++ b/pyomnilogic_local/__init__.py @@ -1,3 +1,4 @@ +from .collections import EffectsCollection, LightEffectsCollection from .omnilogic import OmniLogic from .util import ( OmniConnectionError, @@ -7,6 +8,8 @@ ) __all__ = [ + "EffectsCollection", + "LightEffectsCollection", "OmniLogic", "OmniLogicLocalError", "OmniEquipmentNotReadyError", diff --git a/pyomnilogic_local/collections.py b/pyomnilogic_local/collections.py index e888900..003f545 100644 --- a/pyomnilogic_local/collections.py +++ b/pyomnilogic_local/collections.py @@ -3,9 +3,11 @@ import logging from collections import Counter from collections.abc import Iterator +from enum import Enum from typing import Any, Generic, TypeVar, overload from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.omnitypes import LightShows _LOGGER = logging.getLogger(__name__) @@ -13,10 +15,10 @@ _WARNED_DUPLICATE_NAMES: set[str] = set() # Type variable for equipment types -T = TypeVar("T", bound=OmniEquipment[Any, Any]) +OE = TypeVar("OE", bound=OmniEquipment[Any, Any]) -class EquipmentDict(Generic[T]): +class EquipmentDict(Generic[OE]): """A dictionary-like collection that supports lookup by both name and system_id. This collection allows accessing equipment using either their name (str) or @@ -54,7 +56,7 @@ class EquipmentDict(Generic[T]): always lookup by name. This type-based differentiation prevents ambiguity. """ - def __init__(self, items: list[T] | None = None) -> None: + def __init__(self, items: list[OE] | None = None) -> None: """Initialize the equipment collection. Args: @@ -63,7 +65,7 @@ def __init__(self, items: list[T] | None = None) -> None: Raises: ValueError: If any item has neither a system_id nor a name. """ - self._items: list[T] = items if items is not None else [] + self._items: list[OE] = items if items is not None else [] self._validate() def _validate(self) -> None: @@ -102,22 +104,22 @@ def _validate(self) -> None: _WARNED_DUPLICATE_NAMES.add(name) @property - def _by_name(self) -> dict[str, T]: + def _by_name(self) -> dict[str, OE]: """Dynamically build name-to-equipment mapping.""" return {item.name: item for item in self._items if item.name is not None} @property - def _by_id(self) -> dict[int, T]: + def _by_id(self) -> dict[int, OE]: """Dynamically build system_id-to-equipment mapping.""" return {item.system_id: item for item in self._items if item.system_id is not None} @overload - def __getitem__(self, key: str) -> T: ... + def __getitem__(self, key: str) -> OE: ... @overload - def __getitem__(self, key: int) -> T: ... + def __getitem__(self, key: int) -> OE: ... - def __getitem__(self, key: str | int) -> T: + def __getitem__(self, key: str | int) -> OE: """Get equipment by name (str) or system_id (int). Args: @@ -141,7 +143,7 @@ def __getitem__(self, key: str | int) -> T: raise TypeError(f"Key must be str or int, got {type(key).__name__}") - def __setitem__(self, key: str | int, value: T) -> None: + def __setitem__(self, key: str | int, value: OE) -> None: """Add or update equipment in the collection. The key is only used to determine the operation type (add vs update). @@ -230,7 +232,7 @@ def __contains__(self, key: str | int) -> bool: return False - def __iter__(self) -> Iterator[T]: + def __iter__(self) -> Iterator[OE]: """Iterate over all equipment items in the collection. Returns: @@ -264,7 +266,7 @@ def __repr__(self) -> str: # names = [item.name or f"" for item in self._items] return f"EquipmentDict({names})" - def append(self, item: T) -> None: + def append(self, item: OE) -> None: """Add or update equipment in the collection (list-like interface). If equipment with the same system_id or name already exists, it will be @@ -298,7 +300,7 @@ def append(self, item: T) -> None: # Validate after modification self._validate() - def get_by_name(self, name: str) -> T | None: + def get_by_name(self, name: str) -> OE | None: """Get equipment by name with explicit method (returns None if not found). Args: @@ -314,7 +316,7 @@ def get_by_name(self, name: str) -> T | None: """ return self._by_name.get(name) - def get_by_id(self, system_id: int) -> T | None: + def get_by_id(self, system_id: int) -> OE | None: """Get equipment by system_id with explicit method (returns None if not found). Args: @@ -330,7 +332,7 @@ def get_by_id(self, system_id: int) -> T | None: """ return self._by_id.get(system_id) - def get(self, key: str | int, default: T | None = None) -> T | None: + def get(self, key: str | int, default: OE | None = None) -> OE | None: """Get equipment by name or system_id with optional default. Args: @@ -362,7 +364,7 @@ def keys(self) -> list[str]: """ return list(self._by_name.keys()) - def values(self) -> list[T]: + def values(self) -> list[OE]: """Get list of all equipment items. Returns: @@ -374,7 +376,7 @@ def values(self) -> list[T]: """ return self._items.copy() - def items(self) -> list[tuple[int | None, str | None, T]]: + def items(self) -> list[tuple[int | None, str | None, OE]]: """Get list of (system_id, name, equipment) tuples. Returns: @@ -386,3 +388,135 @@ def items(self) -> list[tuple[int | None, str | None, T]]: ... print(f"ID: {system_id}, Name: {name}") """ return [(item.system_id, item.name, item) for item in self._items] + + +# Type variable for enum types +E = TypeVar("E", bound=Enum) + + +class EffectsCollection(Generic[E]): + """A collection that provides both attribute and dict-like access to light effects. + + This class wraps a list of light shows and exposes them through multiple access patterns: + - Attribute access: `effects.VOODOO_LOUNGE` + - Dict-like access: `effects["VOODOO_LOUNGE"]` + - Iteration: `for effect in effects: ...` + - Length: `len(effects)` + - Membership: `effect in effects` + + The collection is read-only and provides type-safe access to only the shows + supported by a specific light model. + + Example: + >>> light = pool.lights["Pool Light"] + >>> # Attribute access + >>> await light.set_show(light.effects.TROPICAL) + >>> # Dict-like access + >>> await light.set_show(light.effects["TROPICAL"]) + >>> # Check if a show is available + >>> if "VOODOO_LOUNGE" in light.effects: + ... await light.set_show(light.effects.VOODOO_LOUNGE) + >>> # Iterate through available shows + >>> for effect in light.effects: + ... print(f"{effect.name}: {effect.value}") + """ + + def __init__(self, effects: list[E]) -> None: + """Initialize the effects collection. + + Args: + effects: List of light show enums available for this light model. + """ + self._effects = effects + # Create a lookup dict for fast access by name + self._effects_by_name = {effect.name: effect for effect in effects} + + def __getattr__(self, name: str) -> E: + """Enable attribute access to effects by name. + + Args: + name: The name of the light show (e.g., "VOODOO_LOUNGE") + + Returns: + The light show enum value. + + Raises: + AttributeError: If the show name is not available for this light model. + """ + if name.startswith("_"): + # Avoid infinite recursion for internal attributes + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + try: + return self._effects_by_name[name] + except KeyError as exc: + raise AttributeError(f"Light effect '{name}' is not available for this light model") from exc + + def __getitem__(self, key: str | int) -> E: + """Enable dict-like and index access to effects. + + Args: + key: Either the effect name (str) or index position (int) + + Returns: + The light show enum value. + + Raises: + KeyError: If the show name is not available for this light model. + IndexError: If the index is out of range. + TypeError: If key is not a string or integer. + """ + if isinstance(key, str): + try: + return self._effects_by_name[key] + except KeyError as exc: + raise KeyError(f"Light effect '{key}' is not available for this light model") from exc + elif isinstance(key, int): + return self._effects[key] + else: + raise TypeError(f"indices must be integers or strings, not {type(key).__name__}") + + def __contains__(self, item: str | E) -> bool: + """Check if an effect is available in this collection. + + Args: + item: Either the effect name (str) or the effect enum value + + Returns: + True if the effect is available, False otherwise. + + Note: + When checking enum membership, this uses identity checking (is), + not value equality (==). This ensures that only the exact enum + instance from this collection's type is matched, even if different + enum types share the same value. + """ + if isinstance(item, str): + return item in self._effects_by_name + # Use identity check to ensure exact type match + return any(item is effect for effect in self._effects) + + def __iter__(self) -> Iterator[E]: + """Enable iteration over the effects.""" + return iter(self._effects) + + def __len__(self) -> int: + """Return the number of effects in the collection.""" + return len(self._effects) + + def __repr__(self) -> str: + """Return a string representation of the collection.""" + effect_names = [effect.name for effect in self._effects] + return f"EffectsCollection({effect_names})" + + def to_list(self) -> list[E]: + """Return the underlying list of effects. + + Returns: + A list of all light show enums in this collection. + """ + return self._effects.copy() + + +# Type alias for light effects specifically +LightEffectsCollection = EffectsCollection[LightShows] diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index 7852b5d..cc54347 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.collections import LightEffectsCollection from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPColorLogicLight from pyomnilogic_local.models.telemetry import Telemetry, TelemetryColorLogicLight @@ -47,7 +48,8 @@ class ColorLogicLight(OmniEquipment[MSPColorLogicLight, TelemetryColorLogicLight Properties (Configuration): model: Light model type (TWO_FIVE, FOUR_ZERO, UCL, SAM, etc.) v2_active: Whether V2 protocol features are active - effects: List of available light shows for this model + effects: Collection of available light shows for this model with + attribute and dict-like access patterns Properties (Telemetry): state: Current power state (ON, OFF, transitional states) @@ -82,12 +84,16 @@ class ColorLogicLight(OmniEquipment[MSPColorLogicLight, TelemetryColorLogicLight ... print(f"Speed: {pool_light.speed}") ... print(f"Brightness: {pool_light.brightness}") >>> - >>> # Control light + >>> # Control light - use attribute access for available effects! >>> await pool_light.turn_on() - >>> await pool_light.set_show(ColorLogicShow25.TROPICAL) + >>> await pool_light.set_show(pool_light.effects.TROPICAL) >>> await pool_light.set_speed(ColorLogicSpeed.TWO_TIMES) >>> await pool_light.set_brightness(ColorLogicBrightness.SEVENTY_FIVE_PERCENT) >>> await pool_light.turn_off() + >>> + >>> # Or use traditional enum imports + >>> from pyomnilogic_local import ColorLogicShow25 + >>> await pool_light.set_show(ColorLogicShow25.TROPICAL) Important - Light State Transitions: ColorLogic lights go through several transitional states when changing @@ -126,9 +132,38 @@ def v2_active(self) -> bool: return self.mspconfig.v2_active @property - def effects(self) -> list[LightShows] | None: - """Returns the effects of the light.""" - return self.mspconfig.effects + def effects(self) -> LightEffectsCollection | None: + """Returns the available light effects as a collection with attribute and dict-like access. + + The effects collection provides multiple access patterns: + - Attribute access: `light.effects.VOODOO_LOUNGE` + - Dict-like access: `light.effects["VOODOO_LOUNGE"]` + - Index access: `light.effects[0]` + - Membership testing: `"TROPICAL" in light.effects` + - Iteration: `for effect in light.effects: ...` + + Returns: + LightEffectsCollection containing the available shows for this light model, + or None if effects are not available. + + Example: + >>> # Attribute access - most intuitive + >>> await light.set_show(light.effects.TROPICAL) + >>> + >>> # Dict-like access + >>> await light.set_show(light.effects["TROPICAL"]) + >>> + >>> # Check availability + >>> if "VOODOO_LOUNGE" in light.effects: + ... await light.set_show(light.effects.VOODOO_LOUNGE) + >>> + >>> # List all available effects + >>> for effect in light.effects: + ... print(f"{effect.name}: {effect.value}") + """ + if self.mspconfig.effects is None: + return None + return LightEffectsCollection(self.mspconfig.effects) @property def state(self) -> ColorLogicPowerState: diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 25682a1..8d86b1e 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -287,20 +287,6 @@ def __init__(self, **data: Any) -> None: csad_equip_data = [op[OmniType.CSAD_EQUIP] for op in data.get("Operation", {}) if OmniType.CSAD_EQUIP in op] self.csad_equipment = [MSPCSADEquip.model_validate(equip) for equip in csad_equip_data] - # The CSAD equipment are nested down inside a list of "Operations", which also includes non CSAD-Equipment items. - # We need to first filter down to just the CSAD equipment items, then populate our self.csad_equipment with parsed - # versions of those items. - # operations_list = data.get("Operation", []) - # if operations_list: - # csad_equip_ops = [op for op in operations_list if OmniType.CSAD_EQUIP in op] - # if csad_equip_ops: - # csad_equip_data = csad_equip_ops[0][OmniType.CSAD_EQUIP] - # self.csad_equipment = [MSPCSADEquip.model_validate(equip) for equip in csad_equip_data] - # else: - # self.csad_equipment = [] - # else: - # self.csad_equipment = [] - class MSPColorLogicLight(OmniBase): _YES_NO_FIELDS = {"v2_active"} diff --git a/tests/test_effects_collection.py b/tests/test_effects_collection.py new file mode 100644 index 0000000..f583371 --- /dev/null +++ b/tests/test_effects_collection.py @@ -0,0 +1,158 @@ +"""Tests for the EffectsCollection class.""" + +import pytest + +from pyomnilogic_local.collections import EffectsCollection, LightEffectsCollection +from pyomnilogic_local.omnitypes import ( + ColorLogicShow25, + ColorLogicShow40, + ColorLogicShowUCL, +) + + +class TestEffectsCollection: + """Test suite for EffectsCollection.""" + + def test_attribute_access(self) -> None: + """Test that we can access effects by attribute name.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + # Test attribute access + assert effects.VOODOO_LOUNGE == ColorLogicShow25.VOODOO_LOUNGE + assert effects.EMERALD == ColorLogicShow25.EMERALD + assert effects.DEEP_BLUE_SEA == ColorLogicShow25.DEEP_BLUE_SEA + + def test_dict_like_access(self) -> None: + """Test that we can access effects by string key.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + # Test dict-like access + assert effects["VOODOO_LOUNGE"] == ColorLogicShow25.VOODOO_LOUNGE + assert effects["EMERALD"] == ColorLogicShow25.EMERALD + assert effects["DEEP_BLUE_SEA"] == ColorLogicShow25.DEEP_BLUE_SEA + + def test_index_access(self) -> None: + """Test that we can access effects by index.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + # Test index access + assert effects[0] == ColorLogicShow25.VOODOO_LOUNGE + assert effects[1] == ColorLogicShow25.DEEP_BLUE_SEA + assert effects[-1] == ColorLogicShow25.COOL_CABARET + + def test_attribute_error(self) -> None: + """Test that accessing non-existent effect raises AttributeError.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + with pytest.raises(AttributeError, match="Light effect 'NONEXISTENT' is not available"): + _ = effects.NONEXISTENT + + def test_key_error(self) -> None: + """Test that accessing non-existent effect by key raises KeyError.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + with pytest.raises(KeyError, match="Light effect 'NONEXISTENT' is not available"): + _ = effects["NONEXISTENT"] + + def test_index_error(self) -> None: + """Test that accessing out of range index raises IndexError.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + with pytest.raises(IndexError): + _ = effects[999] + + def test_type_error_invalid_key(self) -> None: + """Test that accessing with invalid key type raises TypeError.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + with pytest.raises(TypeError, match="indices must be integers or strings"): + _ = effects[3.14] # type: ignore + + def test_contains_string(self) -> None: + """Test membership testing with string names.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + assert "VOODOO_LOUNGE" in effects + assert "EMERALD" in effects + assert "NONEXISTENT" not in effects + + def test_contains_enum(self) -> None: + """Test membership testing with enum values.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + assert ColorLogicShow25.VOODOO_LOUNGE in effects + assert ColorLogicShow25.EMERALD in effects + # Test that an enum from a different type is not in the collection + # Note: We need to check by type since enum values might overlap + ucl_effects = EffectsCollection(list(ColorLogicShowUCL)) + assert ColorLogicShowUCL.ROYAL_BLUE in ucl_effects + assert ColorLogicShowUCL.ROYAL_BLUE not in effects # type: ignore # ROYAL_BLUE doesn't exist in Show25 + + def test_iteration(self) -> None: + """Test iterating over effects.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + # Test that we can iterate + effects_list = list(effects) + assert len(effects_list) == len(ColorLogicShow25) + assert effects_list[0] == ColorLogicShow25.VOODOO_LOUNGE + + # Test iteration in for loop + count = 0 + for effect in effects: + assert isinstance(effect, ColorLogicShow25) + count += 1 + assert count == len(ColorLogicShow25) + + def test_length(self) -> None: + """Test that len() works correctly.""" + effects25 = EffectsCollection(list(ColorLogicShow25)) + effects40 = EffectsCollection(list(ColorLogicShow40)) + effectsUCL = EffectsCollection(list(ColorLogicShowUCL)) # pylint: disable=invalid-name + + assert len(effects25) == len(ColorLogicShow25) + assert len(effects40) == len(ColorLogicShow40) + assert len(effectsUCL) == len(ColorLogicShowUCL) + + def test_repr(self) -> None: + """Test string representation.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + repr_str = repr(effects) + assert "EffectsCollection" in repr_str + assert "VOODOO_LOUNGE" in repr_str + assert "EMERALD" in repr_str + + def test_to_list(self) -> None: + """Test converting back to a list.""" + original_list = list(ColorLogicShow25) + effects = EffectsCollection(original_list) + + result_list = effects.to_list() + assert result_list == original_list + # Verify it's a copy + assert result_list is not original_list + + def test_type_alias(self) -> None: + """Test that the LightEffectsCollection type alias works.""" + effects: LightEffectsCollection = EffectsCollection(list(ColorLogicShow25)) + + # Should work with any LightShows type + assert effects.VOODOO_LOUNGE == ColorLogicShow25.VOODOO_LOUNGE + + def test_different_show_types(self) -> None: + """Test that different show types are correctly distinguished.""" + effects25 = EffectsCollection(list(ColorLogicShow25)) + effectsUCL = EffectsCollection(list(ColorLogicShowUCL)) # pylint: disable=invalid-name + + # UCL has ROYAL_BLUE, 2.5 doesn't + assert "ROYAL_BLUE" not in effects25 + assert "ROYAL_BLUE" in effectsUCL + + # Both have VOODOO_LOUNGE and they're from different enums + assert effects25.VOODOO_LOUNGE is ColorLogicShow25.VOODOO_LOUNGE + assert effectsUCL.VOODOO_LOUNGE is ColorLogicShowUCL.VOODOO_LOUNGE + # Even though they have the same value (0), they're different enum types + assert type(effects25.VOODOO_LOUNGE) is not type(effectsUCL.VOODOO_LOUNGE) # type: ignore + assert isinstance(effects25.VOODOO_LOUNGE, ColorLogicShow25) + assert isinstance(effectsUCL.VOODOO_LOUNGE, ColorLogicShowUCL) From ba3983e923c61b02abd148cedaf197e01346b873 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:58:32 -0600 Subject: [PATCH 34/61] feat: improvements to api layer error handling --- pyomnilogic_local/api/api.py | 146 +++++++++++++++++++++------- pyomnilogic_local/api/exceptions.py | 30 +++++- pyomnilogic_local/api/protocol.py | 118 +++++++++++++++++----- 3 files changed, 232 insertions(+), 62 deletions(-) diff --git a/pyomnilogic_local/api/api.py b/pyomnilogic_local/api/api.py index e7ac6fb..5ae9edb 100644 --- a/pyomnilogic_local/api/api.py +++ b/pyomnilogic_local/api/api.py @@ -17,18 +17,94 @@ LightShows, MessageType, ) +from .constants import ( + DEFAULT_CONTROLLER_PORT, + DEFAULT_RESPONSE_TIMEOUT, + MAX_SPEED_PERCENT, + MAX_TEMPERATURE_F, + MIN_SPEED_PERCENT, + MIN_TEMPERATURE_F, + XML_ENCODING, + XML_NAMESPACE, +) +from .exceptions import OmniValidationException from .protocol import OmniLogicProtocol _LOGGER = logging.getLogger(__name__) +def _validate_temperature(temperature: int, param_name: str = "temperature") -> None: + """Validate temperature is within acceptable range. + + Args: + temperature: Temperature value in Fahrenheit. + param_name: Name of the parameter for error messages. + + Raises: + OmniValidationException: If temperature is out of range. + """ + if not isinstance(temperature, int): + raise OmniValidationException(f"{param_name} must be an integer, got {type(temperature).__name__}") + if not MIN_TEMPERATURE_F <= temperature <= MAX_TEMPERATURE_F: + raise OmniValidationException(f"{param_name} must be between {MIN_TEMPERATURE_F}°F and {MAX_TEMPERATURE_F}°F, got {temperature}°F") + + +def _validate_speed(speed: int, param_name: str = "speed") -> None: + """Validate speed percentage is within acceptable range. + + Args: + speed: Speed percentage (0-100). + param_name: Name of the parameter for error messages. + + Raises: + OmniValidationException: If speed is out of range. + """ + if not isinstance(speed, int): + raise OmniValidationException(f"{param_name} must be an integer, got {type(speed).__name__}") + if not MIN_SPEED_PERCENT <= speed <= MAX_SPEED_PERCENT: + raise OmniValidationException(f"{param_name} must be between {MIN_SPEED_PERCENT} and {MAX_SPEED_PERCENT}, got {speed}") + + +def _validate_id(id_value: int, param_name: str) -> None: + """Validate an ID is a positive integer. + + Args: + id_value: The ID value to validate. + param_name: Name of the parameter for error messages. + + Raises: + OmniValidationException: If ID is invalid. + """ + if not isinstance(id_value, int): + raise OmniValidationException(f"{param_name} must be an integer, got {type(id_value).__name__}") + if id_value < 0: + raise OmniValidationException(f"{param_name} must be non-negative, got {id_value}") + + class OmniLogicAPI: - def __init__(self, controller_ip: str, controller_port: int, response_timeout: float = 5.0) -> None: + def __init__( + self, controller_ip: str, controller_port: int = DEFAULT_CONTROLLER_PORT, response_timeout: float = DEFAULT_RESPONSE_TIMEOUT + ) -> None: + """Initialize the OmniLogic API client. + + Args: + controller_ip: IP address of the OmniLogic controller. + controller_port: UDP port of the OmniLogic controller (default: 10444). + response_timeout: Timeout in seconds for receiving responses (default: 5.0). + + Raises: + OmniValidationException: If parameters are invalid. + """ + if not controller_ip: + raise OmniValidationException("controller_ip cannot be empty") + if not isinstance(controller_port, int) or controller_port <= 0 or controller_port > 65535: + raise OmniValidationException(f"controller_port must be between 1 and 65535, got {controller_port}") + if not isinstance(response_timeout, (int, float)) or response_timeout <= 0: + raise OmniValidationException(f"response_timeout must be positive, got {response_timeout}") + self.controller_ip = controller_ip self.controller_port = controller_port self.response_timeout = response_timeout - # self._loop = asyncio.get_running_loop() - # self._protocol_factory = OmniLogicProtocol @overload async def async_send_message(self, message_type: MessageType, message: str | None, need_response: Literal[True]) -> str: ... @@ -76,12 +152,12 @@ async def async_get_mspconfig(self, raw: bool = False) -> MSPConfig | str: Returns: MSPConfig|str: Either a parsed .models.mspconfig.MSPConfig object or a str depending on arg raw """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "RequestConfiguration" - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) resp = await self.async_send_message(MessageType.REQUEST_CONFIGURATION, req_body, True) @@ -107,7 +183,7 @@ async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, ra Returns: FilterDiagnostics|str: Either a parsed .models.mspconfig.FilterDiagnostics object or a str depending on arg raw """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "GetUIFilterDiagnosticInfo" @@ -118,7 +194,7 @@ async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, ra parameter = ET.SubElement(parameters_element, "Parameter", name="equipmentId", dataType="int") parameter.text = str(equipment_id) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) resp = await self.async_send_message(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body, True) @@ -138,12 +214,12 @@ async def async_get_telemetry(self, raw: bool = False) -> Telemetry | str: Returns: Telemetry|str: Either a parsed .models.telemetry.Telemetry object or a str depending on arg raw """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "RequestTelemetryData" - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) resp = await self.async_send_message(MessageType.GET_TELEMETRY, req_body, True) @@ -167,7 +243,7 @@ async def async_set_heater( Returns: None """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUIHeaterCmd" @@ -180,7 +256,7 @@ async def async_set_heater( parameter = ET.SubElement(parameters_element, "Parameter", name="Temp", dataType="int", unit="F", alias="Data") parameter.text = str(temperature) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_HEATER_COMMAND, req_body, False) @@ -200,7 +276,7 @@ async def async_set_solar_heater( Returns: None """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUISolarSetPointCmd" @@ -213,7 +289,7 @@ async def async_set_solar_heater( parameter = ET.SubElement(parameters_element, "Parameter", name="Temp", dataType="int", unit="F", alias="Data") parameter.text = str(temperature) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_SOLAR_SET_POINT_COMMAND, req_body, False) @@ -233,7 +309,7 @@ async def async_set_heater_mode( Returns: None """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUIHeaterModeCmd" @@ -246,7 +322,7 @@ async def async_set_heater_mode( parameter = ET.SubElement(parameters_element, "Parameter", name="Mode", dataType="int", alias="Data") parameter.text = str(mode.value) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_HEATER_MODE_COMMAND, req_body, False) @@ -266,7 +342,7 @@ async def async_set_heater_enable( Returns: _type_: _description_ """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetHeaterEnable" @@ -279,7 +355,7 @@ async def async_set_heater_enable( parameter = ET.SubElement(parameters_element, "Parameter", name="Enabled", dataType="bool", alias="Data") parameter.text = str(int(enabled)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_HEATER_ENABLED, req_body, False) @@ -312,7 +388,7 @@ async def async_set_equipment( daysActive (int, optional): For potential future use, included to be "API complete". Defaults to 0. recurring (bool, optional): For potential future use, included to be "API complete". Defaults to False. """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUIEquipmentCmd" @@ -339,7 +415,7 @@ async def async_set_equipment( parameter = ET.SubElement(parameters_element, "Parameter", name="Recurring", dataType="bool") parameter.text = str(int(recurring)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_EQUIPMENT, req_body, False) @@ -351,7 +427,7 @@ async def async_set_filter_speed(self, pool_id: int, equipment_id: int, speed: i equipment_id (int): Which equipment_id within that Pool to address speed (int): Speed value from 0-100 to set the filter to. A value of 0 will turn the filter off. """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUIFilterSpeedCmd" @@ -365,7 +441,7 @@ async def async_set_filter_speed(self, pool_id: int, equipment_id: int, speed: i parameter = ET.SubElement(parameters_element, "Parameter", name="Speed", dataType="int", unit="RPM", alias="Data") parameter.text = str(speed) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_FILTER_SPEED, req_body, False) @@ -402,7 +478,7 @@ async def async_set_light_show( days_active (int, optional): For potential future use, included to be "API complete". Defaults to 0. recurring (bool, optional): For potential future use, included to be "API complete". Defaults to False. """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetStandAloneLightShow" @@ -435,11 +511,11 @@ async def async_set_light_show( parameter = ET.SubElement(parameters_element, "Parameter", name="Recurring", dataType="bool") parameter.text = str(int(recurring)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_STANDALONE_LIGHT_SHOW, req_body, False) async def async_set_chlorinator_enable(self, pool_id: int, enabled: int | bool) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetCHLOREnable" @@ -450,7 +526,7 @@ async def async_set_chlorinator_enable(self, pool_id: int, enabled: int | bool) parameter = ET.SubElement(parameters_element, "Parameter", name="Enabled", dataType="bool", alias="Data") parameter.text = str(int(enabled)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_CHLOR_ENABLED, req_body, False) @@ -466,7 +542,7 @@ async def async_set_chlorinator_params( orp_timeout: int, cfg_state: int = 3, ) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetCHLORParams" @@ -491,7 +567,7 @@ async def async_set_chlorinator_params( parameter = ET.SubElement(parameters_element, "Parameter", name="ORPTimout", dataType="byte", unit="hour", alias="Data7") parameter.text = str(orp_timeout) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_CHLOR_PARAMS, req_body, False) @@ -501,7 +577,7 @@ async def async_set_chlorinator_superchlorinate( equipment_id: int, enabled: int | bool, ) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUISuperCHLORCmd" @@ -514,19 +590,19 @@ async def async_set_chlorinator_superchlorinate( parameter = ET.SubElement(parameters_element, "Parameter", name="IsOn", dataType="byte", alias="Data1") parameter.text = str(int(enabled)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_SUPERCHLORINATE, req_body, False) async def async_restore_idle_state(self) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "RestoreIdleState" ET.SubElement(body_element, "Parameters") - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.RESTORE_IDLE_STATE, req_body, False) @@ -542,7 +618,7 @@ async def async_set_spillover( days_active: int = 0, recurring: bool = False, ) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUISpilloverCmd" @@ -567,7 +643,7 @@ async def async_set_spillover( parameter = ET.SubElement(parameters_element, "Parameter", name="Recurring", dataType="bool") parameter.text = str(int(recurring)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_SPILLOVER, req_body, False) @@ -583,7 +659,7 @@ async def async_set_group_enable( days_active: int = 0, recurring: bool = False, ) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "RunGroupCmd" @@ -608,6 +684,6 @@ async def async_set_group_enable( parameter = ET.SubElement(parameters_element, "Parameter", name="Recurring", dataType="bool") parameter.text = str(int(recurring)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.RUN_GROUP_CMD, req_body, False) diff --git a/pyomnilogic_local/api/exceptions.py b/pyomnilogic_local/api/exceptions.py index 4a10da3..b50917a 100644 --- a/pyomnilogic_local/api/exceptions.py +++ b/pyomnilogic_local/api/exceptions.py @@ -1,6 +1,30 @@ class OmniLogicException(Exception): - pass + """Base exception for all OmniLogic errors.""" -class OmniTimeoutException(OmniLogicException): - pass +class OmniProtocolException(OmniLogicException): + """Protocol-level errors during communication with the OmniLogic controller.""" + + +class OmniTimeoutException(OmniProtocolException): + """Timeout occurred while waiting for a response from the controller.""" + + +class OmniMessageFormatException(OmniProtocolException): + """Received a malformed or invalid message from the controller.""" + + +class OmniFragmentationException(OmniProtocolException): + """Error occurred during message fragmentation or reassembly.""" + + +class OmniConnectionException(OmniLogicException): + """Network connection error occurred.""" + + +class OmniValidationException(OmniLogicException): + """Invalid parameter or configuration value provided.""" + + +class OmniCommandException(OmniLogicException): + """Error occurred while executing a command on the controller.""" diff --git a/pyomnilogic_local/api/protocol.py b/pyomnilogic_local/api/protocol.py index 234d666..d9d42c2 100644 --- a/pyomnilogic_local/api/protocol.py +++ b/pyomnilogic_local/api/protocol.py @@ -11,7 +11,24 @@ from ..models.leadmessage import LeadMessage from ..omnitypes import ClientType, MessageType -from .exceptions import OmniTimeoutException +from .constants import ( + ACK_WAIT_TIMEOUT, + BLOCK_MESSAGE_HEADER_OFFSET, + MAX_FRAGMENT_WAIT_TIME, + MAX_QUEUE_SIZE, + OMNI_RETRANSMIT_COUNT, + OMNI_RETRANSMIT_TIME, + PROTOCOL_HEADER_FORMAT, + PROTOCOL_HEADER_SIZE, + PROTOCOL_VERSION, + XML_ENCODING, + XML_NAMESPACE, +) +from .exceptions import ( + OmniFragmentationException, + OmniMessageFormatException, + OmniTimeoutException, +) _LOGGER = logging.getLogger(__name__) @@ -22,12 +39,12 @@ class OmniLogicMessage: Handles serialization and deserialization of message headers and payloads. """ - header_format = "!LQ4sLBBBB" + header_format = PROTOCOL_HEADER_FORMAT id: int type: MessageType payload: bytes client_type: ClientType = ClientType.SIMPLE - version: str = "1.19" + version: str = PROTOCOL_VERSION timestamp: int | None = int(time.time()) reserved_1: int = 0 compressed: bool = False @@ -38,7 +55,7 @@ def __init__( msg_id: int, msg_type: MessageType, payload: str | None = None, - version: str = "1.19", + version: str = PROTOCOL_VERSION, ) -> None: """ Initialize a new OmniLogicMessage. @@ -96,15 +113,36 @@ def from_bytes(cls, data: bytes) -> Self: data: Byte data received from the controller. Returns: OmniLogicMessage instance. + Raises: + OmniMessageFormatException: If the message format is invalid. """ + if len(data) < PROTOCOL_HEADER_SIZE: + raise OmniMessageFormatException(f"Message too short: {len(data)} bytes, expected at least {PROTOCOL_HEADER_SIZE}") + # split the header and data - header = data[:24] - rdata: bytes = data[24:] + header = data[:PROTOCOL_HEADER_SIZE] + rdata: bytes = data[PROTOCOL_HEADER_SIZE:] + + try: + (msg_id, tstamp, vers, msg_type, client_type, res1, compressed, res2) = struct.unpack(cls.header_format, header) + except struct.error as exc: + raise OmniMessageFormatException(f"Failed to unpack message header: {exc}") from exc + + # Validate message type + try: + message_type_enum = MessageType(msg_type) + except ValueError as exc: + raise OmniMessageFormatException(f"Unknown message type: {msg_type}") from exc + + # Validate client type + try: + client_type_enum = ClientType(int(client_type)) + except ValueError as exc: + raise OmniMessageFormatException(f"Unknown client type: {client_type}") from exc - (msg_id, tstamp, vers, msg_type, client_type, res1, compressed, res2) = struct.unpack(cls.header_format, header) - message = cls(msg_id=msg_id, msg_type=MessageType(msg_type), version=vers.decode("utf-8")) + message = cls(msg_id=msg_id, msg_type=message_type_enum, version=vers.decode("utf-8")) message.timestamp = tstamp - message.client_type = ClientType(int(client_type)) + message.client_type = client_type_enum message.reserved_1 = res1 # There are some messages that are ALWAYS compressed although they do not return a 1 in their LeadMessage message.compressed = compressed == 1 or message.type in [MessageType.MSP_TELEMETRY_UPDATE] @@ -122,9 +160,9 @@ class OmniLogicProtocol(asyncio.DatagramProtocol): transport: asyncio.DatagramTransport # The omni will re-transmit a packet every 2 seconds if it does not receive an ACK. We pad that just a touch to be safe - _omni_retransmit_time = 2.1 + _omni_retransmit_time = OMNI_RETRANSMIT_TIME # The omni will re-transmit 5 times (a total of 6 attempts including the initial) if it does not receive an ACK - _omni_retransmit_count = 5 + _omni_retransmit_count = OMNI_RETRANSMIT_COUNT data_queue: asyncio.Queue[OmniLogicMessage] error_queue: asyncio.Queue[Exception] @@ -133,8 +171,8 @@ def __init__(self) -> None: """ Initialize the protocol handler and message queue. """ - self.data_queue = asyncio.Queue() - self.error_queue = asyncio.Queue() + self.data_queue = asyncio.Queue(maxsize=MAX_QUEUE_SIZE) + self.error_queue = asyncio.Queue(maxsize=MAX_QUEUE_SIZE) def connection_made(self, transport: asyncio.BaseTransport) -> None: """ @@ -161,8 +199,12 @@ def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None: self.data_queue.put_nowait(message) except asyncio.QueueFull: _LOGGER.error("Data queue is full. Dropping message: %s", str(message)) + except OmniMessageFormatException as exc: + _LOGGER.error("Failed to parse incoming datagram from %s: %s", addr, exc) + self.error_queue.put_nowait(exc) except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.error("Failed to parse incoming datagram from %s: %s", addr, exc, exc_info=True) + _LOGGER.error("Unexpected error processing datagram from %s: %s", addr, exc, exc_info=True) + self.error_queue.put_nowait(exc) def error_received(self, exc: Exception) -> None: """ @@ -175,6 +217,9 @@ async def _wait_for_ack(self, ack_id: int) -> None: """ Wait for an ACK message with the given ID. Handles dropped or out-of-order ACKs. + Raises: + OmniTimeoutException: If no ACK is received. + Exception: If a protocol error occurs. """ # Wait for either an ACK message or an error while True: @@ -190,6 +235,7 @@ async def _wait_for_ack(self, ack_id: int) -> None: if data_task in done: message = data_task.result() if message.id == ack_id: + _LOGGER.debug("Received ACK for message ID %s", ack_id) return _LOGGER.debug("We received a message that is not our ACK, it appears the ACK was dropped") if message.type in {MessageType.MSP_LEADMESSAGE, MessageType.MSP_TELEMETRY_UPDATE}: @@ -212,6 +258,7 @@ async def _ensure_sent( """ for attempt in range(0, max_attempts): self.transport.sendto(bytes(message)) + _LOGGER.debug("Sent message ID %s (attempt %d/%d)", message.id, attempt + 1, max_attempts) # If the message that we just sent is an ACK, we do not need to wait to receive an ACK, we are done if message.type in [MessageType.XML_ACK, MessageType.ACK]: @@ -219,7 +266,7 @@ async def _ensure_sent( # Wait for a bit to either receive an ACK for our message, otherwise, we retry delivery try: - await asyncio.wait_for(self._wait_for_ack(message.id), 0.5) + await asyncio.wait_for(self._wait_for_ack(message.id), ACK_WAIT_TIMEOUT) return except TimeoutError as exc: if attempt < max_attempts - 1: @@ -283,11 +330,11 @@ async def _send_ack(self, msg_id: int) -> None: """ Send an ACK message for the given message ID. """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "Ack" - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) await self.send_message(MessageType.XML_ACK, req_body, msg_id) async def _receive_file(self) -> str: @@ -298,6 +345,7 @@ async def _receive_file(self) -> str: Response payload as a string. Raises: OmniTimeoutException: If a block message is not received in time. + OmniFragmentationException: If fragment reassembly fails. """ # wait for the initial packet. message = await self.data_queue.get() @@ -305,50 +353,72 @@ async def _receive_file(self) -> str: # If messages have to be re-transmitted, we can sometimes receive multiple ACKs. The first one would be handled by # self._ensure_sent, but if any subsequent ACKs are sent to us, we need to dump them and wait for a "real" message. while message.type in [MessageType.ACK, MessageType.XML_ACK]: + _LOGGER.debug("Skipping duplicate ACK message") message = await self.data_queue.get() await self._send_ack(message.id) # If the response is too large, the controller will send a LeadMessage indicating how many follow-up messages will be sent if message.type is MessageType.MSP_LEADMESSAGE: - leadmsg = LeadMessage.model_validate(ET.fromstring(message.payload[:-1])) + try: + leadmsg = LeadMessage.model_validate(ET.fromstring(message.payload[:-1])) + except Exception as exc: + raise OmniFragmentationException(f"Failed to parse LeadMessage: {exc}") from exc - _LOGGER.debug("Will receive %s blockmessages", leadmsg.msg_block_count) + _LOGGER.debug("Will receive %s blockmessages for fragmented response", leadmsg.msg_block_count) # Wait for the block data data retval: bytes = b"" # If we received a LeadMessage, continue to receive messages until we have all of our data # Fragments of data may arrive out of order, so we store them in a buffer as they arrive and sort them after data_fragments: dict[int, bytes] = {} + fragment_start_time = time.time() + while len(data_fragments) < leadmsg.msg_block_count: + # Check if we've been waiting too long for fragments + if time.time() - fragment_start_time > MAX_FRAGMENT_WAIT_TIME: + raise OmniFragmentationException( + f"Timeout waiting for fragments: received {len(data_fragments)}/{leadmsg.msg_block_count} after {MAX_FRAGMENT_WAIT_TIME}s" + ) + # We need to wait long enough for the Omni to get through all of it's retries before we bail out. try: resp = await asyncio.wait_for(self.data_queue.get(), self._omni_retransmit_time * self._omni_retransmit_count) except TimeoutError as exc: - raise OmniTimeoutException from exc + raise OmniFragmentationException( + f"Timeout receiving fragment: got {len(data_fragments)}/{leadmsg.msg_block_count} fragments" + ) from exc # We only want to collect blockmessages here if resp.type is not MessageType.MSP_BLOCKMESSAGE: - _LOGGER.debug("Received a message other than a blockmessage: %s", resp.type) + _LOGGER.debug("Received a message other than a blockmessage during fragmentation: %s", resp.type) continue await self._send_ack(resp.id) # remove an 8 byte header to get to the payload data - data_fragments[resp.id] = resp.payload[8:] + data_fragments[resp.id] = resp.payload[BLOCK_MESSAGE_HEADER_OFFSET:] + _LOGGER.debug("Received fragment %d/%d", len(data_fragments), leadmsg.msg_block_count) # Reassemble the fragmets in order for _, data in sorted(data_fragments.items()): retval += data + _LOGGER.debug("Successfully reassembled %d fragments into %d bytes", leadmsg.msg_block_count, len(retval)) + # We did not receive a LeadMessage, so our payload is just this one packet else: retval = message.payload # Decompress the returned data if necessary if message.compressed: - comp_bytes = bytes.fromhex(retval.hex()) - retval = zlib.decompress(comp_bytes) + _LOGGER.debug("Decompressing response payload") + try: + comp_bytes = bytes.fromhex(retval.hex()) + retval = zlib.decompress(comp_bytes) + _LOGGER.debug("Decompressed %d bytes to %d bytes", len(comp_bytes), len(retval)) + except zlib.error as exc: + raise OmniMessageFormatException(f"Failed to decompress message: {exc}") from exc # For some API calls, the Omni null terminates the response, we are stripping that here to make parsing it later easier return retval.decode("utf-8").strip("\x00") From d4e3f36cfaccb6033acd98335391af24001cf8d5 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:06:37 -0600 Subject: [PATCH 35/61] fix: add missing constants.py --- pyomnilogic_local/api/constants.py | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 pyomnilogic_local/api/constants.py diff --git a/pyomnilogic_local/api/constants.py b/pyomnilogic_local/api/constants.py new file mode 100644 index 0000000..912b677 --- /dev/null +++ b/pyomnilogic_local/api/constants.py @@ -0,0 +1,33 @@ +"""Constants for the OmniLogic protocol implementation.""" + +# Protocol Message Constants +PROTOCOL_HEADER_SIZE = 24 # Size of the message header in bytes +PROTOCOL_HEADER_FORMAT = "!LQ4sLBBBB" # struct format for header +PROTOCOL_VERSION = "1.19" # Current protocol version + +# Block Message Constants +BLOCK_MESSAGE_HEADER_OFFSET = 8 # Offset to skip block message header and get to payload + +# Timing Constants (in seconds) +OMNI_RETRANSMIT_TIME = 2.1 # Time Omni waits before retransmitting a packet +OMNI_RETRANSMIT_COUNT = 5 # Number of retransmit attempts (6 total including initial) +ACK_WAIT_TIMEOUT = 0.5 # Timeout waiting for ACK response +DEFAULT_RESPONSE_TIMEOUT = 5.0 # Default timeout for receiving responses + +# Network Constants +DEFAULT_CONTROLLER_PORT = 10444 # Default UDP port for OmniLogic communication + +# Queue Constants +MAX_QUEUE_SIZE = 100 # Maximum number of messages to queue +MAX_FRAGMENT_WAIT_TIME = 30.0 # Maximum time to wait for all fragments (seconds) + +# Validation Constants +MAX_TEMPERATURE_F = 104 # Maximum temperature in Fahrenheit +MIN_TEMPERATURE_F = 65 # Minimum temperature in Fahrenheit +MAX_SPEED_PERCENT = 100 # Maximum speed percentage +MIN_SPEED_PERCENT = 0 # Minimum speed percentage +MAX_MESSAGE_SIZE = 65507 # Maximum UDP payload size (theoretical) + +# XML Constants +XML_NAMESPACE = "http://nextgen.hayward.com/api" # Namespace for XML messages +XML_ENCODING = "unicode" # Encoding for XML output From d5a8b811327b3c40accd7461214243c55d0fc666 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:42:16 -0600 Subject: [PATCH 36/61] feat: improve tests, update omnitypes for relay whyOn --- pyomnilogic_local/models/mspconfig.py | 6 ++--- pyomnilogic_local/omnitypes.py | 35 +++++++++------------------ pyproject.toml | 1 + uv.lock | 24 ++++++++++++++++++ 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 8d86b1e..8ca3f09 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -254,7 +254,7 @@ class MSPCSADEquip(OmniBase): omni_type: OmniType = OmniType.CSAD_EQUIP equip_type: Literal["PET_CSAD"] = Field(alias="Type") - csad_type: CSADEquipmentType | str = Field(alias="CSAD-Type") + csad_type: CSADEquipmentType = Field(alias="CSAD-Type") enabled: bool = Field(alias="Enabled") @@ -265,7 +265,7 @@ class MSPCSAD(OmniBase): omni_type: OmniType = OmniType.CSAD enabled: bool = Field(alias="Enabled") - equip_type: CSADType | str = Field(alias="Type") + equip_type: CSADType = Field(alias="Type") target_value: float = Field(alias="TargetValue") calibration_value: float = Field(alias="CalibrationValue") ph_low_alarm_value: float = Field(alias="PHLowAlarmLevel") @@ -323,7 +323,7 @@ class MSPBoW(OmniBase): omni_type: OmniType = OmniType.BOW - equip_type: BodyOfWaterType | str = Field(alias="Type") + equip_type: BodyOfWaterType = Field(alias="Type") supports_spillover: bool = Field(alias="Supports-Spillover", default=False) filter: list[MSPFilter] | None = Field(alias="Filter", default=None) relay: list[MSPRelay] | None = Field(alias="Relay", default=None) diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index 974bcaa..e5b75db 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -510,29 +510,18 @@ class RelayType(StrEnum, PrettyEnum): class RelayWhyOn(IntEnum, PrettyEnum): - OFF = 0 - ON = 1 - FREEZE_PROTECT = 2 - WAITING_FOR_INTERLOCK = 3 - PAUSED = 4 - WAITING_FOR_FILTER = 5 - UNKNOWN_1 = 6 # We have seen 8, so we assume 6 exists - # whyOn value 7 is assumed to be TIMED_EVENT - # ref: https://github.com/cryptk/haomnilogic-local/issues/150 - # ref: https://github.com/cryptk/haomnilogic-local/issues/60 - # the relay in question is a high voltage relay that is scheduled to run 24x7 - TIMED_EVENT = 7 - # whyOn value 8 is assumed to be GROUP_COMMAND - # ref: https://github.com/cryptk/haomnilogic-local/issues/148 - # ref: https://github.com/cryptk/haomnilogic-local/issues/106 - # the relays in question for for a cleaner and water feature that were activated via a running group command - GROUP_COMMAND = 8 - UNKNOWN_4 = 9 # We have seen 10, so we assume 9 exists - # whyOn value 10 is assumed to be EXTERNAL_INTERLOCK - # ref: https://github.com/cryptk/haomnilogic-local/issues/73 - # the relay in question was a high voltage relay controlling an ozonator interlocked with the filter - # and the filter was on due to a timed event - EXTERNAL_INTERLOCK = 10 + NO_MESSAGE = 0 + MANUAL_OFF = 1 + COUNTDOWN_DONE = 2 + END_SCHEDULE = 3 + GROUP_OFF = 4 + MANUAL_ON = 5 + COUNTDOWN_TIMER = 6 + SCHEDULE_ON = 7 + GROUP_ON = 8 + FREEZE_PROTECT = 9 + INTERLOCK = 10 + MAX_ACTION = 11 # Sensors diff --git a/pyproject.toml b/pyproject.toml index a28d4dc..f7cf44b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,4 +144,5 @@ dev = [ "pytest>=8.0.0,<9.0.0", "pytest-cov>=7.0.0,<8.0.0", "pytest-asyncio>=1.2.0,<2.0.0", + "pytest-subtests>=0.15.0,<1.0.0", ] diff --git a/uv.lock b/uv.lock index d4e1f2b..53916e9 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/f4/034361a9cbd9284ef40c8ad107955ede4efae29cbc17a059f63f6569c06a/astroid-4.0.1-py3-none-any.whl", hash = "sha256:37ab2f107d14dc173412327febf6c78d39590fdafcb44868f03b6c03452e3db0", size = 276268, upload-time = "2025-10-11T15:15:40.585Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -441,6 +450,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-subtests" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" }, +] + [[package]] name = "python-omnilogic-local" version = "0.19.0" @@ -464,6 +486,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-subtests" }, ] [package.metadata] @@ -483,6 +506,7 @@ dev = [ { name = "pytest", specifier = ">=8.0.0,<9.0.0" }, { name = "pytest-asyncio", specifier = ">=1.2.0,<2.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" }, + { name = "pytest-subtests", specifier = ">=0.15.0,<1.0.0" }, ] [[package]] From 70b41ce8c9d2f44679a1080eeda8a044f90acd33 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:43:09 -0600 Subject: [PATCH 37/61] fix: add missing test_api.py --- tests/test_api.py | 570 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 tests/test_api.py diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..e970122 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,570 @@ +# pylint: skip-file +# type: ignore + +""" +Comprehensive tests for the OmniLogic API layer. + +Focuses on: +- Validation function tests (table-driven) +- API initialization tests +- XML message generation tests +- Transport/protocol integration tests +""" + +from unittest.mock import AsyncMock, MagicMock, patch +from xml.etree import ElementTree as ET + +import pytest +from pytest_subtests import SubTests + +from pyomnilogic_local.api.api import ( + OmniLogicAPI, + _validate_id, + _validate_speed, + _validate_temperature, +) +from pyomnilogic_local.api.constants import ( + MAX_SPEED_PERCENT, + MAX_TEMPERATURE_F, + MIN_SPEED_PERCENT, + MIN_TEMPERATURE_F, + XML_NAMESPACE, +) +from pyomnilogic_local.api.exceptions import OmniValidationException +from pyomnilogic_local.omnitypes import ( + ColorLogicBrightness, + ColorLogicShow40, + ColorLogicSpeed, + HeaterMode, +) + +# ============================================================================ +# Helper Functions +# ============================================================================ + + +def _get_xml_tag(element: ET.Element) -> str: + """Strip namespace from XML tag for easier assertions.""" + return element.tag.split("}")[-1] if "}" in element.tag else element.tag + + +def _find_elem(root: ET.Element, path: str) -> ET.Element: + """Find element with namespace support, raising if not found.""" + elem = root.find(f".//{{{XML_NAMESPACE}}}{path}") + if elem is None: + raise AssertionError(f"Element {path} not found in XML") + return elem + + +def _find_param(root: ET.Element, name: str) -> ET.Element: + """Find parameter by name attribute.""" + elem = root.find(f".//{{{XML_NAMESPACE}}}Parameter[@name='{name}']") + if elem is None: + raise AssertionError(f"Parameter {name} not found in XML") + return elem + + +# ============================================================================ +# Validation Function Tests (Table-Driven) +# ============================================================================ + + +def test_validate_temperature(subtests: SubTests) -> None: + """Test temperature validation with various inputs using table-driven approach.""" + test_cases = [ + # (temperature, param_name, should_pass, description) + (MIN_TEMPERATURE_F, "temp", True, "minimum valid temperature"), + (MAX_TEMPERATURE_F, "temp", True, "maximum valid temperature"), + (80, "temp", True, "mid-range valid temperature"), + (MIN_TEMPERATURE_F - 1, "temp", False, "below minimum temperature"), + (MAX_TEMPERATURE_F + 1, "temp", False, "above maximum temperature"), + ("80", "temp", False, "string instead of int"), + (80.5, "temp", False, "float instead of int"), + (None, "temp", False, "None value"), + ] + + for temperature, param_name, should_pass, description in test_cases: + with subtests.test(msg=description, temperature=temperature): + if should_pass: + _validate_temperature(temperature, param_name) # Should not raise + else: + with pytest.raises(OmniValidationException): + _validate_temperature(temperature, param_name) + + +def test_validate_speed(subtests: SubTests) -> None: + """Test speed validation with various inputs using table-driven approach.""" + test_cases = [ + # (speed, param_name, should_pass, description) + (MIN_SPEED_PERCENT, "speed", True, "minimum valid speed (0)"), + (MAX_SPEED_PERCENT, "speed", True, "maximum valid speed (100)"), + (50, "speed", True, "mid-range valid speed"), + (MIN_SPEED_PERCENT - 1, "speed", False, "below minimum speed"), + (MAX_SPEED_PERCENT + 1, "speed", False, "above maximum speed"), + ("50", "speed", False, "string instead of int"), + (50.5, "speed", False, "float instead of int"), + (None, "speed", False, "None value"), + ] + + for speed, param_name, should_pass, description in test_cases: + with subtests.test(msg=description, speed=speed): + if should_pass: + _validate_speed(speed, param_name) # Should not raise + else: + with pytest.raises(OmniValidationException): + _validate_speed(speed, param_name) + + +def test_validate_id(subtests: SubTests) -> None: + """Test ID validation with various inputs using table-driven approach.""" + test_cases = [ + # (id_value, param_name, should_pass, description) + (0, "pool_id", True, "zero ID"), + (1, "pool_id", True, "positive ID"), + (999999, "pool_id", True, "large positive ID"), + (-1, "pool_id", False, "negative ID"), + ("1", "pool_id", False, "string instead of int"), + (1.5, "pool_id", False, "float instead of int"), + (None, "pool_id", False, "None value"), + ] + + for id_value, param_name, should_pass, description in test_cases: + with subtests.test(msg=description, id_value=id_value): + if should_pass: + _validate_id(id_value, param_name) # Should not raise + else: + with pytest.raises(OmniValidationException): + _validate_id(id_value, param_name) + + +# ============================================================================ +# OmniLogicAPI Constructor Tests +# ============================================================================ + + +def test_api_init_valid() -> None: + """Test OmniLogicAPI initialization with valid parameters.""" + api = OmniLogicAPI("192.168.1.100") + assert api.controller_ip == "192.168.1.100" + assert api.controller_port == 10444 + assert api.response_timeout == 5.0 + + +def test_api_init_custom_params() -> None: + """Test OmniLogicAPI initialization with custom parameters.""" + api = OmniLogicAPI("10.0.0.50", controller_port=12345, response_timeout=10.0) + assert api.controller_ip == "10.0.0.50" + assert api.controller_port == 12345 + assert api.response_timeout == 10.0 + + +def test_api_init_validation(subtests: SubTests) -> None: + """Test OmniLogicAPI initialization validation using table-driven approach.""" + test_cases = [ + # (ip, port, timeout, should_pass, description) + ("", 10444, 5.0, False, "empty IP address"), + ("192.168.1.100", 0, 5.0, False, "zero port"), + ("192.168.1.100", -1, 5.0, False, "negative port"), + ("192.168.1.100", 65536, 5.0, False, "port too high"), + ("192.168.1.100", "10444", 5.0, False, "port as string"), + ("192.168.1.100", 10444, 0, False, "zero timeout"), + ("192.168.1.100", 10444, -1, False, "negative timeout"), + ("192.168.1.100", 10444, "5.0", False, "timeout as string"), + ] + + for ip, port, timeout, should_pass, description in test_cases: + with subtests.test(msg=description): + if should_pass: + api = OmniLogicAPI(ip, port, timeout) + assert api is not None + else: + with pytest.raises(OmniValidationException): + OmniLogicAPI(ip, port, timeout) + + +# ============================================================================ +# Message Generation Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_async_get_mspconfig_generates_valid_xml() -> None: + """Test that async_get_mspconfig generates valid XML request.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = 'Configuration' + + await api.async_get_mspconfig(raw=True) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "RequestConfiguration" + + +@pytest.mark.asyncio +async def test_async_get_telemetry_generates_valid_xml() -> None: + """Test that async_get_telemetry generates valid XML request.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = 'Telemetry' + + await api.async_get_telemetry(raw=True) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "RequestTelemetryData" + + +@pytest.mark.asyncio +async def test_async_get_filter_diagnostics_generates_valid_xml() -> None: + """Test that async_get_filter_diagnostics generates valid XML with correct parameters.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = 'FilterDiagnostics' + + await api.async_get_filter_diagnostics(pool_id=1, equipment_id=2, raw=True) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "GetUIFilterDiagnosticInfo" + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "equipmentId").text == "2" + + +@pytest.mark.asyncio +async def test_async_set_heater_generates_valid_xml() -> None: + """Test that async_set_heater generates valid XML with correct parameters.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_heater(pool_id=1, equipment_id=2, temperature=75) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetUIHeaterCmd" + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "HeaterID").text == "2" + temp_param = _find_param(root, "Temp") + assert temp_param.text == "75" + assert temp_param.get("unit") == "F" + + +@pytest.mark.asyncio +async def test_async_set_filter_speed_generates_valid_xml() -> None: + """Test that async_set_filter_speed generates valid XML with correct parameters.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_filter_speed(pool_id=1, equipment_id=2, speed=75) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetUIFilterSpeedCmd" + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "FilterID").text == "2" + assert _find_param(root, "Speed").text == "75" + + +@pytest.mark.asyncio +async def test_async_set_equipment_generates_valid_xml() -> None: + """Test that async_set_equipment generates valid XML with correct parameters.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_equipment( + pool_id=1, + equipment_id=2, + is_on=True, + is_countdown_timer=False, + start_time_hours=10, + start_time_minutes=30, + end_time_hours=14, + end_time_minutes=45, + days_active=127, + recurring=True, + ) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetUIEquipmentCmd" + + # Verify all parameters + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "equipmentId").text == "2" + assert _find_param(root, "isOn").text == "1" + assert _find_param(root, "IsCountDownTimer").text == "0" + assert _find_param(root, "StartTimeHours").text == "10" + assert _find_param(root, "StartTimeMinutes").text == "30" + assert _find_param(root, "EndTimeHours").text == "14" + assert _find_param(root, "EndTimeMinutes").text == "45" + assert _find_param(root, "DaysActive").text == "127" + assert _find_param(root, "Recurring").text == "1" + + +@pytest.mark.asyncio +async def test_async_set_heater_mode_generates_valid_xml() -> None: + """Test that async_set_heater_mode generates valid XML with correct enum values.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_heater_mode(pool_id=1, equipment_id=2, mode=HeaterMode.HEAT) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetUIHeaterModeCmd" + assert _find_param(root, "Mode").text == str(HeaterMode.HEAT.value) + + +@pytest.mark.asyncio +async def test_async_set_light_show_generates_valid_xml() -> None: + """Test that async_set_light_show generates valid XML with correct enum values.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_light_show( + pool_id=1, + equipment_id=2, + show=ColorLogicShow40.DEEP_BLUE_SEA, + speed=ColorLogicSpeed.TWO_TIMES, + brightness=ColorLogicBrightness.EIGHTY_PERCENT, + ) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetStandAloneLightShow" + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "LightID").text == "2" + assert _find_param(root, "Show").text == str(ColorLogicShow40.DEEP_BLUE_SEA.value) + assert _find_param(root, "Speed").text == str(ColorLogicSpeed.TWO_TIMES.value) + assert _find_param(root, "Brightness").text == str(ColorLogicBrightness.EIGHTY_PERCENT.value) + + +@pytest.mark.asyncio +async def test_async_set_chlorinator_enable_boolean_conversion(subtests: SubTests) -> None: + """Test that async_set_chlorinator_enable properly converts boolean to int.""" + api = OmniLogicAPI("192.168.1.100") + + test_cases = [ + (True, "1", "boolean True"), + (False, "0", "boolean False"), + (1, "1", "int 1"), + (0, "0", "int 0"), + ] + + for enabled, expected, description in test_cases: + with subtests.test(msg=description): + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_chlorinator_enable(pool_id=1, enabled=enabled) + + call_args = mock_send.call_args + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _find_param(root, "Enabled").text == expected + + +@pytest.mark.asyncio +async def test_async_set_heater_enable_boolean_conversion(subtests: SubTests) -> None: + """Test that async_set_heater_enable properly converts boolean to int.""" + api = OmniLogicAPI("192.168.1.100") + + test_cases = [ + (True, "1", "boolean True"), + (False, "0", "boolean False"), + (1, "1", "int 1"), + (0, "0", "int 0"), + ] + + for enabled, expected, description in test_cases: + with subtests.test(msg=description): + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_heater_enable(pool_id=1, equipment_id=2, enabled=enabled) + + call_args = mock_send.call_args + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _find_param(root, "Enabled").text == expected + + +@pytest.mark.asyncio +async def test_async_set_chlorinator_params_generates_valid_xml() -> None: + """Test that async_set_chlorinator_params generates valid XML with all parameters.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_chlorinator_params( + pool_id=1, + equipment_id=2, + timed_percent=50, + cell_type=3, + op_mode=1, + sc_timeout=24, + bow_type=0, + orp_timeout=12, + cfg_state=3, + ) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetCHLORParams" + + # Verify all parameters + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "ChlorID").text == "2" + assert _find_param(root, "CfgState").text == "3" + assert _find_param(root, "OpMode").text == "1" + assert _find_param(root, "BOWType").text == "0" + assert _find_param(root, "CellType").text == "3" + assert _find_param(root, "TimedPercent").text == "50" + assert _find_param(root, "SCTimeout").text == "24" + assert _find_param(root, "ORPTimout").text == "12" + + +# ============================================================================ +# async_send_message Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_async_send_message_creates_transport() -> None: + """Test that async_send_message creates a UDP transport.""" + api = OmniLogicAPI("192.168.1.100", controller_port=10444) + + mock_transport = MagicMock() + mock_protocol = AsyncMock() + mock_protocol.send_message = AsyncMock() + + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) + + await api.async_send_message(0x01, "test", need_response=False) + + # Verify endpoint was created with correct parameters + mock_loop.return_value.create_datagram_endpoint.assert_called_once() + call_kwargs = mock_loop.return_value.create_datagram_endpoint.call_args[1] + assert call_kwargs["remote_addr"] == ("192.168.1.100", 10444) + + # Verify transport was closed + mock_transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_send_message_with_response() -> None: + """Test that async_send_message with need_response=True calls send_and_receive.""" + api = OmniLogicAPI("192.168.1.100") + + mock_transport = MagicMock() + mock_protocol = AsyncMock() + mock_protocol.send_and_receive = AsyncMock(return_value="test response") + + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) + + result = await api.async_send_message(0x01, "test", need_response=True) + + assert result == "test response" + mock_protocol.send_and_receive.assert_called_once() + mock_transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_send_message_without_response() -> None: + """Test that async_send_message with need_response=False calls send_message.""" + api = OmniLogicAPI("192.168.1.100") + + mock_transport = MagicMock() + mock_protocol = AsyncMock() + mock_protocol.send_message = AsyncMock() + + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) + + result = await api.async_send_message(0x01, "test", need_response=False) + + assert result is None + mock_protocol.send_message.assert_called_once() + mock_transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_send_message_closes_transport_on_error() -> None: + """Test that async_send_message closes transport even when an error occurs.""" + api = OmniLogicAPI("192.168.1.100") + + mock_transport = MagicMock() + mock_protocol = AsyncMock() + mock_protocol.send_message = AsyncMock(side_effect=Exception("Test error")) + + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) + + with pytest.raises(Exception, match="Test error"): + await api.async_send_message(0x01, "test", need_response=False) + + # Verify transport was still closed despite the error + mock_transport.close.assert_called_once() From c9a3c16ed8e1691a8be2051d87dd3ae0285a4b44 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:53:09 -0600 Subject: [PATCH 38/61] feat: update tests for underlying protocol handling --- tests/test_protocol.py | 691 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 681 insertions(+), 10 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index c78edd9..00b189f 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,14 +1,39 @@ # pylint: skip-file +# type: ignore + +""" +Enhanced comprehensive tests for the OmniLogic protocol layer. + +Focuses on: +- OmniLogicMessage parsing and serialization (table-driven) +- Protocol error handling +- Message fragmentation and reassembly +- ACK waiting and retry logic +- Connection lifecycle +""" import asyncio -from unittest.mock import MagicMock, patch +import struct +import time +import zlib +from unittest.mock import AsyncMock, MagicMock, patch +from xml.etree import ElementTree as ET import pytest +from pytest_subtests import SubTests -from pyomnilogic_local.api.exceptions import OmniTimeoutException +from pyomnilogic_local.api.exceptions import ( + OmniFragmentationException, + OmniMessageFormatException, + OmniTimeoutException, +) from pyomnilogic_local.api.protocol import OmniLogicMessage, OmniLogicProtocol from pyomnilogic_local.omnitypes import ClientType, MessageType +# ============================================================================ +# OmniLogicMessage Tests +# ============================================================================ + def test_parse_basic_ack() -> None: """Validate that we can parse a basic ACK packet""" @@ -29,7 +54,7 @@ def test_create_basic_ack() -> None: assert bytes(message) == bytes_ack -def test_parse_leadmessate() -> None: +def test_parse_leadmessage() -> None: """Validate that we can parse an MSP LeadMessage.""" bytes_leadmessage = ( b'\x00\x00\x90v\x00\x00\x00\x00dv\x92\xc11.20\x00\x00\x07\xce\x03\x00\x01\x00' @@ -39,7 +64,6 @@ def test_parse_leadmessate() -> None: b"\x00" ) message = OmniLogicMessage.from_bytes(bytes_leadmessage) - print(message.timestamp) assert message.id == 36982 assert message.type is MessageType.MSP_LEADMESSAGE assert message.timestamp == 1685492417 @@ -69,36 +93,351 @@ def test_create_leadmessage() -> None: assert bytes(message) == bytes_leadmessage +def test_message_from_bytes_errors(subtests: SubTests) -> None: + """Test OmniLogicMessage.from_bytes with various error conditions using table-driven approach.""" + test_cases = [ + # (data, expected_error, description) + (b"short", OmniMessageFormatException, "message too short"), + (b"\x00" * 10, OmniMessageFormatException, "header too short"), + ] + + for data, expected_error, description in test_cases: + with subtests.test(msg=description): + with pytest.raises(expected_error): + OmniLogicMessage.from_bytes(data) + + +def test_message_from_bytes_invalid_message_type() -> None: + """Test parsing with an invalid message type.""" + # Create a valid header but with invalid message type (9999) + header = struct.pack( + "!LQ4sLBBBB", + 12345, # msg_id + int(time.time()), # timestamp + b"1.20", # version + 9999, # invalid message type + 0, # client_type + 0, # reserved + 0, # compressed + 0, # reserved + ) + + with pytest.raises(OmniMessageFormatException, match="Unknown message type"): + OmniLogicMessage.from_bytes(header + b"payload") + + +def test_message_from_bytes_invalid_client_type() -> None: + """Test parsing with an invalid client type.""" + # Create a valid header but with invalid client type (99) + header = struct.pack( + "!LQ4sLBBBB", + 12345, # msg_id + int(time.time()), # timestamp + b"1.20", # version + MessageType.ACK.value, # valid message type + 99, # invalid client_type + 0, # reserved + 0, # compressed + 0, # reserved + ) + + with pytest.raises(OmniMessageFormatException, match="Unknown client type"): + OmniLogicMessage.from_bytes(header + b"payload") + + +def test_message_repr_with_blockmessage() -> None: + """Test that __repr__ for MSP_BLOCKMESSAGE doesn't include body.""" + message = OmniLogicMessage(123, MessageType.MSP_BLOCKMESSAGE, payload="test") + repr_str = str(message) + assert "Body:" not in repr_str + assert "MSP_BLOCKMESSAGE" in repr_str + + +def test_message_telemetry_always_compressed() -> None: + """Test that MSP_TELEMETRY_UPDATE is always marked as compressed.""" + header = struct.pack( + "!LQ4sLBBBB", + 12345, # msg_id + int(time.time()), # timestamp + b"1.20", # version + MessageType.MSP_TELEMETRY_UPDATE.value, + 0, # client_type + 0, # reserved + 0, # compressed flag is 0, but should be set to True + 0, # reserved + ) + + message = OmniLogicMessage.from_bytes(header + b"payload") + assert message.compressed is True # Should be True even though flag was 0 + + +def test_message_client_type_xml_vs_simple() -> None: + """Test that messages with payload use XML client type.""" + msg_with_payload = OmniLogicMessage(123, MessageType.REQUEST_CONFIGURATION, payload="") + assert msg_with_payload.client_type == ClientType.XML + + msg_without_payload = OmniLogicMessage(456, MessageType.ACK, payload=None) + assert msg_without_payload.client_type == ClientType.SIMPLE + + +def test_message_payload_null_termination() -> None: + """Test that payload is properly null-terminated.""" + message = OmniLogicMessage(123, MessageType.REQUEST_CONFIGURATION, payload="test") + assert message.payload == b"test\x00" + + +# ============================================================================ +# OmniLogicProtocol Initialization and Connection Tests +# ============================================================================ + + +def test_protocol_initialization() -> None: + """Test that protocol initializes with correct queue sizes.""" + protocol = OmniLogicProtocol() + assert protocol.data_queue.maxsize == 100 + assert protocol.error_queue.maxsize == 100 + + +def test_protocol_connection_made() -> None: + """Test that connection_made sets the transport.""" + protocol = OmniLogicProtocol() + mock_transport = MagicMock() + + protocol.connection_made(mock_transport) + + assert protocol.transport is mock_transport + + +def test_protocol_connection_lost_with_exception() -> None: + """Test that connection_lost raises exception if provided.""" + protocol = OmniLogicProtocol() + test_exception = RuntimeError("Connection error") + + with pytest.raises(RuntimeError, match="Connection error"): + protocol.connection_lost(test_exception) + + +def test_protocol_connection_lost_without_exception() -> None: + """Test that connection_lost without exception doesn't raise.""" + protocol = OmniLogicProtocol() + protocol.connection_lost(None) # Should not raise + + +# ============================================================================ +# Datagram Received Tests +# ============================================================================ + + +def test_datagram_received_valid_message() -> None: + """Test that valid messages are added to the queue.""" + protocol = OmniLogicProtocol() + valid_data = bytes(OmniLogicMessage(123, MessageType.ACK)) + + protocol.datagram_received(valid_data, ("127.0.0.1", 12345)) + + assert protocol.data_queue.qsize() == 1 + message = protocol.data_queue.get_nowait() + assert message.id == 123 + assert message.type == MessageType.ACK + + def test_datagram_received_with_corrupt_data(caplog: pytest.LogCaptureFixture) -> None: """Test that corrupt datagram data is handled gracefully and logged.""" protocol = OmniLogicProtocol() - # Provide invalid/corrupt data (too short for header) corrupt_data = b"short" + with caplog.at_level("ERROR"): protocol.datagram_received(corrupt_data, ("127.0.0.1", 12345)) + assert any("Failed to parse incoming datagram" in r.message for r in caplog.records) + assert protocol.error_queue.qsize() == 1 def test_datagram_received_queue_overflow(caplog: pytest.LogCaptureFixture) -> None: """Test that queue overflow is handled and logged.""" protocol = OmniLogicProtocol() - # Fill the queue to capacity protocol.data_queue = asyncio.Queue(maxsize=1) protocol.data_queue.put_nowait(OmniLogicMessage(1, MessageType.ACK)) - # Now send another valid message + valid_data = bytes(OmniLogicMessage(2, MessageType.ACK)) with caplog.at_level("ERROR"): protocol.datagram_received(valid_data, ("127.0.0.1", 12345)) + assert any("Data queue is full" in r.message for r in caplog.records) +def test_datagram_received_unexpected_exception(caplog: pytest.LogCaptureFixture) -> None: + """Test that unexpected exceptions during datagram processing are handled.""" + protocol = OmniLogicProtocol() + + # Patch OmniLogicMessage.from_bytes to raise an unexpected exception + with patch("pyomnilogic_local.api.protocol.OmniLogicMessage.from_bytes", side_effect=RuntimeError("Unexpected")): + with caplog.at_level("ERROR"): + protocol.datagram_received(b"data", ("127.0.0.1", 12345)) + + assert any("Unexpected error processing datagram" in r.message for r in caplog.records) + assert protocol.error_queue.qsize() == 1 + + +def test_error_received() -> None: + """Test that error_received puts errors in the error queue.""" + protocol = OmniLogicProtocol() + test_error = RuntimeError("UDP error") + + protocol.error_received(test_error) + + assert protocol.error_queue.qsize() == 1 + error = protocol.error_queue.get_nowait() + assert error is test_error + + +# ============================================================================ +# _wait_for_ack Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_wait_for_ack_success() -> None: + """Test successful ACK waiting.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Put an ACK message in the queue + ack_message = OmniLogicMessage(123, MessageType.ACK) + await protocol.data_queue.put(ack_message) + + # Should return without raising + await protocol._wait_for_ack(123) + + +@pytest.mark.asyncio +async def test_wait_for_ack_wrong_id_continues_waiting() -> None: + """Test that wrong ACK IDs are consumed and waiting continues for the correct one.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Put wrong ID first, then correct ID + wrong_ack = OmniLogicMessage(999, MessageType.ACK) + correct_ack = OmniLogicMessage(123, MessageType.ACK) + + await protocol.data_queue.put(wrong_ack) + await protocol.data_queue.put(correct_ack) + + await protocol._wait_for_ack(123) + # Queue should be empty after consuming both messages + assert protocol.data_queue.qsize() == 0 + + +@pytest.mark.asyncio +async def test_wait_for_ack_leadmessage_instead(caplog: pytest.LogCaptureFixture) -> None: + """Test that LeadMessage with matching ID is accepted (ACK was dropped).""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Put a LeadMessage with matching ID (simulating dropped ACK) + leadmsg = OmniLogicMessage(123, MessageType.MSP_LEADMESSAGE) + await protocol.data_queue.put(leadmsg) + + with caplog.at_level("DEBUG"): + await protocol._wait_for_ack(123) + + # With matching ID, it's treated as the ACK we're looking for + assert any("Received ACK for message ID 123" in r.message for r in caplog.records) + # LeadMessage should NOT be in queue since IDs matched + assert protocol.data_queue.qsize() == 0 + + +@pytest.mark.asyncio +async def test_wait_for_ack_leadmessage_wrong_id(caplog: pytest.LogCaptureFixture) -> None: + """Test that LeadMessage with wrong ID is put back in queue and waiting continues.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Put a LeadMessage with wrong ID, then correct ACK + leadmsg = OmniLogicMessage(999, MessageType.MSP_LEADMESSAGE) + correct_ack = OmniLogicMessage(123, MessageType.ACK) + + await protocol.data_queue.put(leadmsg) + await protocol.data_queue.put(correct_ack) + + with caplog.at_level("DEBUG"): + await protocol._wait_for_ack(123) + + # Should log that ACK was dropped and put LeadMessage back + assert any("ACK was dropped" in r.message for r in caplog.records) + # Both messages were consumed and LeadMessage was put back, so queue should have 1 item + # But the ACK was also consumed, so we actually end up with just the LeadMessage back + # Actually, looking at the code: LeadMessage gets put back, then we return + # So BOTH the correct ACK and the LeadMessage should be in the queue + assert protocol.data_queue.qsize() == 2 # LeadMessage put back, correct ACK also still there + + +@pytest.mark.asyncio +async def test_wait_for_ack_error_in_queue() -> None: + """Test that errors from error queue are raised.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + test_error = RuntimeError("Test error") + await protocol.error_queue.put(test_error) + + with pytest.raises(RuntimeError, match="Test error"): + await protocol._wait_for_ack(123) + + +# ============================================================================ +# _ensure_sent Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_ensure_sent_ack_message() -> None: + """Test that ACK messages don't wait for ACK.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + ack_message = OmniLogicMessage(123, MessageType.ACK) + + # Should return immediately without waiting + await protocol._ensure_sent(ack_message) + + protocol.transport.sendto.assert_called_once() + + +@pytest.mark.asyncio +async def test_ensure_sent_xml_ack_message() -> None: + """Test that XML_ACK messages don't wait for ACK.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + xml_ack_message = OmniLogicMessage(123, MessageType.XML_ACK, payload="") + + await protocol._ensure_sent(xml_ack_message) + + protocol.transport.sendto.assert_called_once() + + +@pytest.mark.asyncio +async def test_ensure_sent_success_first_attempt() -> None: + """Test successful send on first attempt.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Mock _wait_for_ack to succeed immediately + with patch.object(protocol, "_wait_for_ack", new_callable=AsyncMock) as mock_wait: + message = OmniLogicMessage(123, MessageType.REQUEST_CONFIGURATION) + await protocol._ensure_sent(message, max_attempts=3) + + protocol.transport.sendto.assert_called_once() + mock_wait.assert_called_once_with(123) + + @pytest.mark.asyncio async def test_ensure_sent_timeout_and_retry_logs(caplog: pytest.LogCaptureFixture) -> None: """Test that _ensure_sent logs retries and raises on repeated timeout.""" protocol = OmniLogicProtocol() protocol.transport = MagicMock() - # Patch _wait_for_ack to always timeout using patch.object async def always_timeout(*args: object, **kwargs: object) -> None: await asyncio.sleep(0) raise TimeoutError() @@ -107,8 +446,340 @@ async def always_timeout(*args: object, **kwargs: object) -> None: with patch.object(protocol, "_wait_for_ack", always_timeout): with caplog.at_level("WARNING"): with pytest.raises(OmniTimeoutException): - await protocol._ensure_sent(message, max_attempts=3) # pylint: disable=protected-access - # Should log retries and final error + await protocol._ensure_sent(message, max_attempts=3) + assert any("attempt 1/3" in r.message for r in caplog.records) assert any("attempt 2/3" in r.message for r in caplog.records) assert any("after 3 attempts" in r.message for r in caplog.records) + assert protocol.transport.sendto.call_count == 3 + + +# ============================================================================ +# send_message and send_and_receive Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_send_message_generates_random_id() -> None: + """Test that send_message generates a random ID when none provided.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + with patch.object(protocol, "_ensure_sent", new_callable=AsyncMock) as mock_ensure: + await protocol.send_message(MessageType.REQUEST_CONFIGURATION, None, msg_id=None) + + mock_ensure.assert_called_once() + sent_message = mock_ensure.call_args[0][0] + assert sent_message.id != 0 # Should have a random ID + + +@pytest.mark.asyncio +async def test_send_message_uses_provided_id() -> None: + """Test that send_message uses provided ID.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + with patch.object(protocol, "_ensure_sent", new_callable=AsyncMock) as mock_ensure: + await protocol.send_message(MessageType.REQUEST_CONFIGURATION, None, msg_id=12345) + + mock_ensure.assert_called_once() + sent_message = mock_ensure.call_args[0][0] + assert sent_message.id == 12345 + + +@pytest.mark.asyncio +async def test_send_and_receive() -> None: + """Test send_and_receive calls send_message and _receive_file.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + with ( + patch.object(protocol, "send_message", new_callable=AsyncMock) as mock_send, + patch.object(protocol, "_receive_file", new_callable=AsyncMock) as mock_receive, + ): + mock_receive.return_value = "test response" + + result = await protocol.send_and_receive(MessageType.REQUEST_CONFIGURATION, "payload", 123) + + mock_send.assert_called_once_with(MessageType.REQUEST_CONFIGURATION, "payload", 123) + mock_receive.assert_called_once() + assert result == "test response" + + +# ============================================================================ +# _send_ack Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_send_ack_generates_xml() -> None: + """Test that _send_ack generates proper XML ACK message.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + with patch.object(protocol, "send_message", new_callable=AsyncMock) as mock_send: + await protocol._send_ack(12345) + + mock_send.assert_called_once() + call_args = mock_send.call_args + assert call_args[0][0] == MessageType.XML_ACK + assert call_args[0][2] == 12345 + + # Verify XML structure + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + assert "Request" in root.tag + name_elem = root.find(".//{http://nextgen.hayward.com/api}Name") + assert name_elem is not None + assert name_elem.text == "Ack" + + +# ============================================================================ +# _receive_file Tests - Simple Response +# ============================================================================ + + +@pytest.mark.asyncio +async def test_receive_file_simple_response() -> None: + """Test receiving a simple (non-fragmented) response.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Create a simple response message + response_msg = OmniLogicMessage(123, MessageType.GET_TELEMETRY, payload="") + await protocol.data_queue.put(response_msg) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock) as mock_ack: + result = await protocol._receive_file() + + mock_ack.assert_called_once_with(123) + assert result == "" + + +@pytest.mark.asyncio +async def test_receive_file_skips_duplicate_acks(caplog: pytest.LogCaptureFixture) -> None: + """Test that duplicate ACKs are skipped.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Put duplicate ACKs followed by real message + ack1 = OmniLogicMessage(111, MessageType.ACK) + ack2 = OmniLogicMessage(222, MessageType.XML_ACK) + response = OmniLogicMessage(333, MessageType.GET_TELEMETRY, payload="") + + await protocol.data_queue.put(ack1) + await protocol.data_queue.put(ack2) + await protocol.data_queue.put(response) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock), caplog.at_level("DEBUG"): + result = await protocol._receive_file() + + assert any("Skipping duplicate ACK" in r.message for r in caplog.records) + assert result == "" + + +@pytest.mark.asyncio +async def test_receive_file_decompresses_data() -> None: + """Test that compressed responses are decompressed.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Create compressed payload + original = b"This is test data that will be compressed" + compressed = zlib.compress(original) + + # Create message with compressed payload + response_msg = OmniLogicMessage(123, MessageType.GET_TELEMETRY) + response_msg.compressed = True + response_msg.payload = compressed + + await protocol.data_queue.put(response_msg) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock): + result = await protocol._receive_file() + + assert result == original.decode("utf-8") + + +@pytest.mark.asyncio +async def test_receive_file_decompression_error() -> None: + """Test that decompression errors are handled.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Create message with invalid compressed data + response_msg = OmniLogicMessage(123, MessageType.GET_TELEMETRY) + response_msg.compressed = True + response_msg.payload = b"invalid compressed data" + + await protocol.data_queue.put(response_msg) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock): + with pytest.raises(OmniMessageFormatException, match="Failed to decompress"): + await protocol._receive_file() + + +# ============================================================================ +# _receive_file Tests - Fragmented Response +# ============================================================================ + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_response() -> None: + """Test receiving a fragmented response with LeadMessage and BlockMessages.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Create LeadMessage + leadmsg_payload = ( + 'LeadMessage' + '100324' + '20' + "" + ) + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload) + + # Create BlockMessages with 8-byte header + block1 = OmniLogicMessage(101, MessageType.MSP_BLOCKMESSAGE) + block1.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"first_part" + + block2 = OmniLogicMessage(102, MessageType.MSP_BLOCKMESSAGE) + block2.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"second_part" + + await protocol.data_queue.put(leadmsg) + await protocol.data_queue.put(block1) + await protocol.data_queue.put(block2) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock) as mock_ack: + result = await protocol._receive_file() + + # Should send ACK for LeadMessage and each BlockMessage + assert mock_ack.call_count == 3 + assert result == "first_partsecond_part" + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_out_of_order() -> None: + """Test that fragments received out of order are reassembled correctly.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + leadmsg_payload = ( + 'LeadMessage' + '100330' + '30' + "" + ) + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload) + + # Create blocks out of order (IDs: 102, 100, 101) + block2 = OmniLogicMessage(102, MessageType.MSP_BLOCKMESSAGE) + block2.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"third" + + block0 = OmniLogicMessage(100, MessageType.MSP_BLOCKMESSAGE) + block0.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"first" + + block1 = OmniLogicMessage(101, MessageType.MSP_BLOCKMESSAGE) + block1.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"second" + + await protocol.data_queue.put(leadmsg) + await protocol.data_queue.put(block2) # Out of order + await protocol.data_queue.put(block0) + await protocol.data_queue.put(block1) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock): + result = await protocol._receive_file() + + # Should be reassembled in ID order + assert result == "firstsecondthird" + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_invalid_leadmessage() -> None: + """Test that invalid LeadMessage XML raises error.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Create LeadMessage with invalid XML + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload="invalid xml") + await protocol.data_queue.put(leadmsg) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock): + with pytest.raises(OmniFragmentationException, match="Failed to parse LeadMessage"): + await protocol._receive_file() + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_timeout_waiting() -> None: + """Test timeout while waiting for fragments.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + leadmsg_payload = ( + 'LeadMessage' + '100324' + '20' + "" + ) + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload) + + await protocol.data_queue.put(leadmsg) + # Don't put any BlockMessages - will timeout + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock): + with pytest.raises(OmniFragmentationException, match="Timeout receiving fragment"): + await protocol._receive_file() + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_max_wait_time_exceeded() -> None: + """Test that MAX_FRAGMENT_WAIT_TIME timeout is enforced.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + leadmsg_payload = ( + 'LeadMessage' + '100324' + '20' + "" + ) + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload) + + await protocol.data_queue.put(leadmsg) + + # Mock time to simulate timeout + with patch.object(protocol, "_send_ack", new_callable=AsyncMock), patch("time.time") as mock_time: + mock_time.side_effect = [0, 31] # Start at 0, then 31 seconds later (> 30s max) + + with pytest.raises(OmniFragmentationException, match="Timeout waiting for fragments"): + await protocol._receive_file() + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_ignores_non_block_messages(caplog: pytest.LogCaptureFixture) -> None: + """Test that non-BlockMessages during fragmentation are ignored.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + leadmsg_payload = ( + 'LeadMessage' + '100310' + '10' + "" + ) + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload) + + # Put LeadMessage, then an ACK (should be ignored), then the actual block + ack_msg = OmniLogicMessage(999, MessageType.ACK) + block1 = OmniLogicMessage(101, MessageType.MSP_BLOCKMESSAGE) + block1.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"data" + + await protocol.data_queue.put(leadmsg) + await protocol.data_queue.put(ack_msg) + await protocol.data_queue.put(block1) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock), caplog.at_level("DEBUG"): + result = await protocol._receive_file() + + assert any("other than a blockmessage" in r.message for r in caplog.records) + assert result == "data" From e9e97ee73418a04a4b7628cd4672d6ec3bc18a30 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:25:16 -0600 Subject: [PATCH 39/61] fix: also allow str for CSAD Equipment types --- pyomnilogic_local/models/mspconfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 8ca3f09..4d30274 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -254,7 +254,7 @@ class MSPCSADEquip(OmniBase): omni_type: OmniType = OmniType.CSAD_EQUIP equip_type: Literal["PET_CSAD"] = Field(alias="Type") - csad_type: CSADEquipmentType = Field(alias="CSAD-Type") + csad_type: CSADEquipmentType | str = Field(alias="CSAD-Type") enabled: bool = Field(alias="Enabled") From a26401fae3315dbccae021c14246fa443c8a2323 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Tue, 4 Nov 2025 13:23:57 -0600 Subject: [PATCH 40/61] feat: add support for groups/themes, including turn_on/off --- .vscode/settings.json | 2 +- pyomnilogic_local/groups.py | 111 ++++++++++++++++++++++++++ pyomnilogic_local/models/mspconfig.py | 21 ++++- pyomnilogic_local/models/telemetry.py | 3 +- pyomnilogic_local/omnilogic.py | 18 +++++ pyomnilogic_local/omnitypes.py | 6 ++ 6 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 pyomnilogic_local/groups.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 43d710e..164a6ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "files.trimTrailingWhitespace": true, - "editor.rulers": [140], + "editor.rulers": [80, 100, 140], // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings "python.testing.pytestEnabled": true, "python.testing.pytestArgs": [ diff --git a/pyomnilogic_local/groups.py b/pyomnilogic_local/groups.py new file mode 100644 index 0000000..94ba6a3 --- /dev/null +++ b/pyomnilogic_local/groups.py @@ -0,0 +1,111 @@ +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.models.mspconfig import MSPGroup +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryGroup +from pyomnilogic_local.omnitypes import GroupState +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + + +class Group(OmniEquipment[MSPGroup, TelemetryGroup]): + """Represents a group in the OmniLogic system. + + Groups allow multiple pieces of equipment to be controlled together as a single unit. + When a group is activated, all equipment assigned to that group will turn on/off together. + This provides convenient one-touch control for common pool/spa scenarios. + + Groups are defined in the OmniLogic configuration and can include any combination + of relays, pumps, lights, heaters, and other controllable equipment. + + Within the OmniLogic App and Web Interface Groups are referred to as Themes. + Within this library the term "Group" is used as that is how they are referred to in the + MSPConfig. + + Attributes: + mspconfig: Configuration data for this group from MSP XML + telemetry: Real-time state data for the group + + Properties: + icon_id: The icon identifier for the group (used in UI displays) + state: Current state of the group (ON or OFF) + is_on: True if the group is currently active + + Control Methods: + turn_on(): Activate all equipment in the group + turn_off(): Deactivate all equipment in the group + + Example: + >>> omni = OmniLogic(...) + >>> await omni.connect() + >>> + >>> # Access a group by name + >>> all_features = omni.groups["All Features"] + >>> + >>> # Check current state + >>> if all_features.is_on: + ... print("All features are currently active") + >>> + >>> # Control the group + >>> await all_features.turn_on() # Turn on all equipment in group + >>> await all_features.turn_off() # Turn off all equipment in group + >>> + >>> # Get group properties + >>> print(f"Group: {all_features.name}") + >>> print(f"Icon ID: {all_features.icon_id}") + >>> print(f"System ID: {all_features.system_id}") + + Note: + - Groups control multiple pieces of equipment simultaneously + - Group membership is defined in OmniLogic configuration + - Within the config, there is data for what equipment is in each group, but this library + does not currently expose that membership information within the interaction layer. + """ + + mspconfig: MSPGroup + telemetry: TelemetryGroup + + def __init__(self, omni: "OmniLogic", mspconfig: MSPGroup, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def icon_id(self) -> int: + """Returns the icon ID for the group.""" + return self.mspconfig.icon_id + + @property + def state(self) -> GroupState: + """Returns the current state of the group.""" + return self.telemetry.state + + @property + def is_on(self) -> bool: + """Returns whether the group is currently active.""" + return self.state == GroupState.ON + + @dirties_state() + async def turn_on(self) -> None: + """ + Activates the group, turning on all equipment assigned to it. + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + """ + if self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn on group: system_id is None") + await self._api.async_set_group_enable(self.system_id, True) + + @dirties_state() + async def turn_off(self) -> None: + """ + Deactivates the group, turning off all equipment assigned to it. + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + """ + if self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn off group: system_id is None") + await self._api.async_set_group_enable(self.system_id, False) diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 4d30274..3f9a58b 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -317,6 +317,12 @@ def __init__(self, **data: Any) -> None: self.effects = list(ZodiacShow) +class MSPGroup(OmniBase): + omni_type: OmniType = OmniType.GROUP + + icon_id: int = Field(alias="Icon-Id") + + class MSPBoW(OmniBase): _sub_devices = {"filter", "relay", "heater", "sensor", "colorlogic_light", "pump", "chlorinator", "csad"} _YES_NO_FIELDS = {"supports_spillover"} @@ -379,6 +385,7 @@ class MSPSchedule(OmniBase): | MSPCSAD | MSPCSADEquip | MSPColorLogicLight + | MSPGroup ) type MSPConfigType = MSPSystem | MSPEquipmentType @@ -389,6 +396,18 @@ class MSPConfig(BaseModel): system: MSPSystem = Field(alias="System") backyard: MSPBackyard = Field(alias="Backyard") + groups: list[MSPGroup] | None = None + + def __init__(self, **data: Any) -> None: + # Extract groups from the Groups container if present + group_data: dict[Any, Any] | None = None + if (groups_data := data.get("Groups", None)) is not None: + group_data = groups_data.get("Group", None) + + if group_data is not None: + data["groups"] = [MSPGroup.model_validate(g) for g in group_data] + + super().__init__(**data) @staticmethod def load_xml(xml: str) -> MSPConfig: @@ -402,7 +421,7 @@ def load_xml(xml: str) -> MSPConfig: OmniType.CL_LIGHT, OmniType.FAVORITES, OmniType.FILTER, - OmniType.GROUPS, + OmniType.GROUP, OmniType.PUMP, OmniType.RELAY, OmniType.SENSOR, diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index d466ee6..44458e8 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -23,6 +23,7 @@ FilterState, FilterValvePosition, FilterWhyOn, + GroupState, HeaterMode, HeaterState, LightShows, @@ -244,7 +245,7 @@ class TelemetryGroup(BaseModel): omni_type: OmniType = OmniType.GROUP system_id: int = Field(alias="@systemId") - state: int = Field(alias="@groupState") + state: GroupState = Field(alias="@groupState") class TelemetryHeater(BaseModel): diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index f8eae5d..ff5a0bf 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -13,6 +13,7 @@ from pyomnilogic_local.csad import CSAD from pyomnilogic_local.csad_equip import CSADEquipment from pyomnilogic_local.filter import Filter +from pyomnilogic_local.groups import Group from pyomnilogic_local.heater import Heater from pyomnilogic_local.heater_equip import HeaterEquipment from pyomnilogic_local.models import MSPConfig, Telemetry @@ -30,6 +31,7 @@ class OmniLogic: system: System backyard: Backyard + groups: EquipmentDict[Group] _mspconfig_last_updated: float = 0.0 _telemetry_last_updated: float = 0.0 @@ -137,6 +139,20 @@ def _update_equipment(self) -> None: except AttributeError: self.backyard = Backyard(self, self.mspconfig.backyard, self.telemetry) + # Update groups + # groups_list: list[Group] = [] + # if self.mspconfig.groups is not None: + # for group_config in self.mspconfig.groups: + # group_telemetry = self.telemetry.get_telem_by_systemid(group_config.system_id) + # if (group_telemetry := self.telemetry.get_telem_by_systemid(group_config.system_id)) is not None: + # groups_list.append(Group(self, group_config, self.telemetry)) + + if self.mspconfig.groups is None: + self.groups = EquipmentDict() + return + + self.groups = EquipmentDict([Group(self, group_, self.telemetry) for group_ in self.mspconfig.groups]) + # Equipment discovery properties @property def all_lights(self) -> EquipmentDict[ColorLogicLight]: @@ -261,6 +277,7 @@ def get_equipment_by_name(self, name: str) -> OmniEquipment[Any, Any] | None: all_equipment.extend(self.all_chlorinator_equipment.values()) all_equipment.extend(self.all_csads.values()) all_equipment.extend(self.all_csad_equipment.values()) + all_equipment.extend(self.groups.values()) for equipment in all_equipment: if equipment.name == name: @@ -291,6 +308,7 @@ def get_equipment_by_id(self, system_id: int) -> OmniEquipment[Any, Any] | None: all_equipment.extend(self.all_chlorinator_equipment.values()) all_equipment.extend(self.all_csads.values()) all_equipment.extend(self.all_csad_equipment.values()) + all_equipment.extend(self.groups.values()) for equipment in all_equipment: if equipment.system_id == system_id: diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index e5b75db..9949790 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -417,6 +417,12 @@ class FilterSpeedPresets(StrEnum, PrettyEnum): HIGH = auto() +# Groups +class GroupState(IntEnum, PrettyEnum): + OFF = 0 + ON = 1 + + # Heaters class HeaterState(IntEnum, PrettyEnum): OFF = 0 From 11aa138cd431d0852c100867679efc377ab17d0c Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:27:24 -0600 Subject: [PATCH 41/61] feat: add support for EditUIScheduleCmd API command --- pyomnilogic_local/api/api.py | 73 +++++++++++++++++++++++++ pyomnilogic_local/cli/debug/commands.py | 63 +++++++++++++++++++++ pyomnilogic_local/omnitypes.py | 1 + 3 files changed, 137 insertions(+) diff --git a/pyomnilogic_local/api/api.py b/pyomnilogic_local/api/api.py index 5ae9edb..694f93a 100644 --- a/pyomnilogic_local/api/api.py +++ b/pyomnilogic_local/api/api.py @@ -687,3 +687,76 @@ async def async_set_group_enable( req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.RUN_GROUP_CMD, req_body, False) + + async def async_edit_schedule( + self, + equipment_id: int, + data: int, + action_id: int, + start_time_hours: int, + start_time_minutes: int, + end_time_hours: int, + end_time_minutes: int, + days_active: int, + is_enabled: bool, + recurring: bool, + ) -> None: + """Edit an existing schedule on the Omni. + + Args: + equipment_id (int): The schedule's system ID (schedule-system-id from MSPConfig), NOT the equipment-id. + This identifies which schedule to edit. + data (int): The data value for the schedule action (e.g., 50 for 50% speed, 1 for on, 0 for off). + Maps to the 'data' field in the schedule and is passed to the action. + action_id (int): The action/event ID that will be executed (e.g., 164 for SetUIEquipmentCmd). + Maps to the 'event' field in the schedule. Common values: + - 164: SetUIEquipmentCmd (turn equipment on/off or set speed) + - 308: SetStandAloneLightShow + - 311: SetUISpilloverCmd + start_time_hours (int): Hour to start the schedule (0-23). Maps to 'start-hour'. + start_time_minutes (int): Minute to start the schedule (0-59). Maps to 'start-minute'. + end_time_hours (int): Hour to end the schedule (0-23). Maps to 'end-hour'. + end_time_minutes (int): Minute to end the schedule (0-59). Maps to 'end-minute'. + days_active (int): Bitmask of active days. Maps to 'days-active'. + 1=Monday, 2=Tuesday, 4=Wednesday, 8=Thursday, 16=Friday, 32=Saturday, 64=Sunday + 127=All days (1+2+4+8+16+32+64) + is_enabled (bool): Whether the schedule is enabled. Maps to 'enabled' (0 or 1). + recurring (bool): Whether the schedule repeats. Maps to 'recurring' (0 or 1). + + Returns: + None + + Note: + The schedule's equipment-id (which equipment is controlled) cannot be changed via this call. + Only the schedule parameters (timing, data, enabled state) can be modified. + """ + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) + + name_element = ET.SubElement(body_element, "Name") + name_element.text = "EditUIScheduleCmd" + + parameters_element = ET.SubElement(body_element, "Parameters") + parameter = ET.SubElement(parameters_element, "Parameter", name="EquipmentID", dataType="int") + parameter.text = str(equipment_id) + parameter = ET.SubElement(parameters_element, "Parameter", name="Data", dataType="int") + parameter.text = str(data) + parameter = ET.SubElement(parameters_element, "Parameter", name="ActionID", dataType="int") + parameter.text = str(action_id) + parameter = ET.SubElement(parameters_element, "Parameter", name="StartTimeHours", dataType="int") + parameter.text = str(start_time_hours) + parameter = ET.SubElement(parameters_element, "Parameter", name="StartTimeMinutes", dataType="int") + parameter.text = str(start_time_minutes) + parameter = ET.SubElement(parameters_element, "Parameter", name="EndTimeHours", dataType="int") + parameter.text = str(end_time_hours) + parameter = ET.SubElement(parameters_element, "Parameter", name="EndTimeMinutes", dataType="int") + parameter.text = str(end_time_minutes) + parameter = ET.SubElement(parameters_element, "Parameter", name="DaysActive", dataType="int") + parameter.text = str(days_active) + parameter = ET.SubElement(parameters_element, "Parameter", name="IsEnabled", dataType="bool") + parameter.text = str(int(is_enabled)) + parameter = ET.SubElement(parameters_element, "Parameter", name="Recurring", dataType="bool") + parameter.text = str(int(recurring)) + + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) + + return await self.async_send_message(MessageType.EDIT_SCHEDULE, req_body, False) diff --git a/pyomnilogic_local/cli/debug/commands.py b/pyomnilogic_local/cli/debug/commands.py index d0684e8..d392936 100644 --- a/pyomnilogic_local/cli/debug/commands.py +++ b/pyomnilogic_local/cli/debug/commands.py @@ -136,3 +136,66 @@ def parse_pcap(ctx: click.Context, pcap_file: Path) -> None: click.echo("Decoded message content:") click.echo(decoded_content) click.echo() # Extra newline for readability + + +@debug.command() +@click.argument("bow_id", type=int) +@click.argument("equip_id", type=int) +@click.argument("is_on") +@click.pass_context +def set_equipment(ctx: click.Context, bow_id: int, equip_id: int, is_on: str) -> None: + """Control equipment by turning it on/off or setting a value. + + BOW_ID: The Body of Water (pool/spa) system ID + EQUIP_ID: The equipment system ID to control + IS_ON: Equipment state - can be: + - Boolean: true/false, on/off, 1/0 + - Integer: 0-100 for variable speed equipment (0=off, 1-100=speed percentage) + + For most equipment (relays, lights), use true/false or 1/0. + For variable speed pumps/filters, use 0-100 to set speed percentage. + + Examples: + # Turn on a relay + omnilogic --host 192.168.1.100 debug set-equipment 7 10 true + + # Turn off a light + omnilogic --host 192.168.1.100 debug set-equipment 7 15 false + + # Set pump to 50% speed + omnilogic --host 192.168.1.100 debug set-equipment 7 8 50 + + # Turn off pump (0% speed) + omnilogic --host 192.168.1.100 debug set-equipment 7 8 0 + """ + ensure_connection(ctx) + omni: OmniLogicAPI = ctx.obj["OMNI"] + + # Parse is_on parameter - can be bool-like string or integer + is_on_lower = is_on.lower() + if is_on_lower in ("true", "on", "yes", "1"): + is_on_value: int | bool = True + elif is_on_lower in ("false", "off", "no", "0"): + is_on_value = False + else: + # Try to parse as integer for variable speed equipment + try: + is_on_value = int(is_on) + if not 0 <= is_on_value <= 100: + click.echo(f"Error: Integer value must be between 0-100, got {is_on_value}", err=True) + raise click.Abort() + except ValueError as exc: + click.echo(f"Error: Invalid value '{is_on}'. Use true/false, on/off, or 0-100 for speed.", err=True) + raise click.Abort() from exc + + # Execute the command + try: + asyncio.run(omni.async_set_equipment(bow_id, equip_id, is_on_value)) + if isinstance(is_on_value, bool): + state = "ON" if is_on_value else "OFF" + click.echo(f"Successfully set equipment {equip_id} in BOW {bow_id} to {state}") + else: + click.echo(f"Successfully set equipment {equip_id} in BOW {bow_id} to {is_on_value}%") + except Exception as e: + click.echo(f"Error setting equipment: {e}", err=True) + raise click.Abort() diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index 9949790..de5fac8 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -18,6 +18,7 @@ class MessageType(Enum): SET_EQUIPMENT = 164 CREATE_SCHEDULE = 230 DELETE_SCHEDULE = 231 + EDIT_SCHEDULE = 233 GET_TELEMETRY = 300 SET_STANDALONE_LIGHT_SHOW = 308 SET_SPILLOVER = 311 From b9092abd3402d446b808772420d7854066fdf431 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:37:20 -0600 Subject: [PATCH 42/61] feat: add more equipment to CLI get command --- pyomnilogic_local/cli/get/chlorinators.py | 81 +++++++++++++++++++++ pyomnilogic_local/cli/get/commands.py | 12 ++++ pyomnilogic_local/cli/get/csads.py | 74 +++++++++++++++++++ pyomnilogic_local/cli/get/groups.py | 72 +++++++++++++++++++ pyomnilogic_local/cli/get/pumps.py | 77 ++++++++++++++++++++ pyomnilogic_local/cli/get/relays.py | 86 +++++++++++++++++++++++ pyomnilogic_local/cli/get/sensors.py | 74 +++++++++++++++++++ pyomnilogic_local/cli/get/valves.py | 2 + 8 files changed, 478 insertions(+) create mode 100644 pyomnilogic_local/cli/get/chlorinators.py create mode 100644 pyomnilogic_local/cli/get/csads.py create mode 100644 pyomnilogic_local/cli/get/groups.py create mode 100644 pyomnilogic_local/cli/get/pumps.py create mode 100644 pyomnilogic_local/cli/get/relays.py create mode 100644 pyomnilogic_local/cli/get/sensors.py diff --git a/pyomnilogic_local/cli/get/chlorinators.py b/pyomnilogic_local/cli/get/chlorinators.py new file mode 100644 index 0000000..a5c139d --- /dev/null +++ b/pyomnilogic_local/cli/get/chlorinators.py @@ -0,0 +1,81 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from typing import Any, cast + +import click + +from pyomnilogic_local.models.mspconfig import ( + MSPChlorinator, + MSPConfig, +) +from pyomnilogic_local.models.telemetry import ( + Telemetry, + TelemetryChlorinator, +) +from pyomnilogic_local.omnitypes import ( + ChlorinatorCellType, + ChlorinatorDispenserType, + ChlorinatorOperatingMode, +) + + +@click.command() +@click.pass_context +def chlorinators(ctx: click.Context) -> None: + """List all chlorinators and their current settings. + + Displays information about all chlorinators including their system IDs, names, + salt levels, operational status, alerts, and errors. + + Example: + omnilogic get chlorinators + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + telemetry: Telemetry = ctx.obj["TELEMETRY"] + + chlorinators_found = False + + # Check for chlorinators in Bodies of Water + if mspconfig.backyard.bow: + for bow in mspconfig.backyard.bow: + if bow.chlorinator: + chlorinators_found = True + _print_chlorinator_info( + bow.chlorinator, cast(TelemetryChlorinator, telemetry.get_telem_by_systemid(bow.chlorinator.system_id)) + ) + + if not chlorinators_found: + click.echo("No chlorinators found in the system configuration.") + + +def _print_chlorinator_info(chlorinator: MSPChlorinator, telemetry: TelemetryChlorinator | None) -> None: + """Format and print chlorinator information in a nice table format. + + Args: + chlorinator: Chlorinator object from MSPConfig with attributes to display + telemetry: Telemetry object containing current state information + """ + click.echo("\n" + "=" * 60) + click.echo("CHLORINATOR") + click.echo("=" * 60) + + chlor_data: dict[Any, Any] = {**dict(chlorinator), **dict(telemetry)} if telemetry else dict(chlorinator) + for attr_name, value in chlor_data.items(): + if attr_name == "cell_type": + value = ChlorinatorCellType(value).pretty() + elif attr_name == "dispenser_type": + value = ChlorinatorDispenserType(value).pretty() + elif attr_name == "operating_mode": + value = ChlorinatorOperatingMode(value).pretty() + elif attr_name in ("status", "alerts", "errors") and isinstance(value, list): + # These are computed properties that return lists of flag names + value = ", ".join(value) if value else "None" + elif isinstance(value, list): + # Format other lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/commands.py b/pyomnilogic_local/cli/get/commands.py index 597b162..37c3f6a 100644 --- a/pyomnilogic_local/cli/get/commands.py +++ b/pyomnilogic_local/cli/get/commands.py @@ -6,9 +6,15 @@ from pyomnilogic_local.cli import ensure_connection from pyomnilogic_local.cli.get.backyard import backyard from pyomnilogic_local.cli.get.bows import bows +from pyomnilogic_local.cli.get.chlorinators import chlorinators +from pyomnilogic_local.cli.get.csads import csads from pyomnilogic_local.cli.get.filters import filters +from pyomnilogic_local.cli.get.groups import groups from pyomnilogic_local.cli.get.heaters import heaters from pyomnilogic_local.cli.get.lights import lights +from pyomnilogic_local.cli.get.pumps import pumps +from pyomnilogic_local.cli.get.relays import relays +from pyomnilogic_local.cli.get.sensors import sensors from pyomnilogic_local.cli.get.valves import valves @@ -28,7 +34,13 @@ def get(ctx: click.Context) -> None: # Register subcommands get.add_command(backyard) get.add_command(bows) +get.add_command(chlorinators) +get.add_command(csads) get.add_command(filters) +get.add_command(groups) get.add_command(heaters) get.add_command(lights) +get.add_command(pumps) +get.add_command(relays) +get.add_command(sensors) get.add_command(valves) diff --git a/pyomnilogic_local/cli/get/csads.py b/pyomnilogic_local/cli/get/csads.py new file mode 100644 index 0000000..df018c5 --- /dev/null +++ b/pyomnilogic_local/cli/get/csads.py @@ -0,0 +1,74 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from typing import Any, cast + +import click + +from pyomnilogic_local.models.mspconfig import ( + MSPCSAD, + MSPConfig, +) +from pyomnilogic_local.models.telemetry import ( + Telemetry, + TelemetryCSAD, +) +from pyomnilogic_local.omnitypes import ( + CSADMode, + CSADType, +) + + +@click.command() +@click.pass_context +def csads(ctx: click.Context) -> None: + """List all CSAD (Chemistry Sense and Dispense) systems and their current settings. + + Displays information about all CSAD systems including their system IDs, names, + current pH/ORP readings, mode, and target values. + + Example: + omnilogic get csads + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + telemetry: Telemetry = ctx.obj["TELEMETRY"] + + csads_found = False + + # Check for CSADs in Bodies of Water + if mspconfig.backyard.bow: + for bow in mspconfig.backyard.bow: + if bow.csad: + for csad in bow.csad: + csads_found = True + _print_csad_info(csad, cast(TelemetryCSAD, telemetry.get_telem_by_systemid(csad.system_id))) + + if not csads_found: + click.echo("No CSAD systems found in the system configuration.") + + +def _print_csad_info(csad: MSPCSAD, telemetry: TelemetryCSAD | None) -> None: + """Format and print CSAD information in a nice table format. + + Args: + csad: CSAD object from MSPConfig with attributes to display + telemetry: Telemetry object containing current state information + """ + click.echo("\n" + "=" * 60) + click.echo("CSAD (CHEMISTRY SENSE AND DISPENSE)") + click.echo("=" * 60) + + csad_data: dict[Any, Any] = {**dict(csad), **dict(telemetry)} if telemetry else dict(csad) + for attr_name, value in csad_data.items(): + if attr_name == "equip_type": + value = CSADType(value).pretty() + elif attr_name == "mode": + value = CSADMode(value).pretty() + elif isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/groups.py b/pyomnilogic_local/cli/get/groups.py new file mode 100644 index 0000000..26a77e4 --- /dev/null +++ b/pyomnilogic_local/cli/get/groups.py @@ -0,0 +1,72 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from typing import Any, cast + +import click + +from pyomnilogic_local.models.mspconfig import ( + MSPConfig, + MSPGroup, +) +from pyomnilogic_local.models.telemetry import ( + Telemetry, + TelemetryGroup, +) +from pyomnilogic_local.omnitypes import ( + GroupState, +) + + +@click.command() +@click.pass_context +def groups(ctx: click.Context) -> None: + """List all groups and their current settings. + + Displays information about all groups including their system IDs, names, + current state, and icon IDs. + + Example: + omnilogic get groups + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + telemetry: Telemetry = ctx.obj["TELEMETRY"] + + groups_found = False + + # Check for groups at the top level + if mspconfig.groups: + for group in mspconfig.groups: + groups_found = True + _print_group_info(group, cast(TelemetryGroup, telemetry.get_telem_by_systemid(group.system_id))) + + if not groups_found: + click.echo("No groups found in the system configuration.") + + +def _print_group_info(group: MSPGroup, telemetry: TelemetryGroup | None) -> None: + """Format and print group information in a nice table format. + + Args: + group: Group object from MSPConfig with attributes to display + telemetry: Telemetry object containing current state information + """ + click.echo("\n" + "=" * 60) + click.echo("GROUP") + click.echo("=" * 60) + + group_data: dict[Any, Any] = {**dict(group), **dict(telemetry)} if telemetry else dict(group) + for attr_name, value in group_data.items(): + if attr_name == "bow_id": + # Skip bow_id as it's not relevant for groups + continue + if attr_name == "state": + value = GroupState(value).pretty() + elif isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/pumps.py b/pyomnilogic_local/cli/get/pumps.py new file mode 100644 index 0000000..f1cdb48 --- /dev/null +++ b/pyomnilogic_local/cli/get/pumps.py @@ -0,0 +1,77 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from typing import Any + +import click + +from pyomnilogic_local.models.mspconfig import ( + MSPConfig, + MSPPump, +) +from pyomnilogic_local.models.telemetry import ( + Telemetry, + TelemetryType, +) +from pyomnilogic_local.omnitypes import ( + PumpFunction, + PumpState, + PumpType, +) + + +@click.command() +@click.pass_context +def pumps(ctx: click.Context) -> None: + """List all pumps and their current settings. + + Displays information about all pumps including their system IDs, names, + current state, speed settings, and pump type. + + Example: + omnilogic get pumps + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + telemetry: Telemetry = ctx.obj["TELEMETRY"] + + pumps_found = False + + # Check for pumps in Bodies of Water + if mspconfig.backyard.bow: + for bow in mspconfig.backyard.bow: + if bow.pump: + for pump in bow.pump: + pumps_found = True + _print_pump_info(pump, telemetry.get_telem_by_systemid(pump.system_id)) + + if not pumps_found: + click.echo("No pumps found in the system configuration.") + + +def _print_pump_info(pump: MSPPump, telemetry: TelemetryType | None) -> None: + """Format and print pump information in a nice table format. + + Args: + pump: Pump object from MSPConfig with attributes to display + telemetry: Telemetry object containing current state information + """ + click.echo("\n" + "=" * 60) + click.echo("PUMP") + click.echo("=" * 60) + + pump_data: dict[Any, Any] = {**dict(pump), **dict(telemetry)} if telemetry else dict(pump) + for attr_name, value in pump_data.items(): + if attr_name == "state": + value = PumpState(value).pretty() + elif attr_name == "equip_type": + value = PumpType(value).pretty() + elif attr_name == "function": + value = PumpFunction(value).pretty() + elif isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/relays.py b/pyomnilogic_local/cli/get/relays.py new file mode 100644 index 0000000..a61d585 --- /dev/null +++ b/pyomnilogic_local/cli/get/relays.py @@ -0,0 +1,86 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from typing import Any + +import click + +from pyomnilogic_local.models.mspconfig import ( + MSPConfig, + MSPRelay, +) +from pyomnilogic_local.models.telemetry import ( + Telemetry, + TelemetryType, +) +from pyomnilogic_local.omnitypes import ( + RelayFunction, + RelayState, + RelayType, + RelayWhyOn, +) + + +@click.command() +@click.pass_context +def relays(ctx: click.Context) -> None: + """List all relays and their current settings. + + Displays information about all relays including their system IDs, names, + current state, type, and function. + + Example: + omnilogic get relays + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + telemetry: Telemetry = ctx.obj["TELEMETRY"] + + relays_found = False + + # Check for relays in the backyard + if mspconfig.backyard.relay: + for relay in mspconfig.backyard.relay: + relays_found = True + _print_relay_info(relay, telemetry.get_telem_by_systemid(relay.system_id)) + + # Check for relays in Bodies of Water + if mspconfig.backyard.bow: + for bow in mspconfig.backyard.bow: + if bow.relay: + for relay in bow.relay: + relays_found = True + _print_relay_info(relay, telemetry.get_telem_by_systemid(relay.system_id)) + + if not relays_found: + click.echo("No relays found in the system configuration.") + + +def _print_relay_info(relay: MSPRelay, telemetry: TelemetryType | None) -> None: + """Format and print relay information in a nice table format. + + Args: + relay: Relay object from MSPConfig with attributes to display + telemetry: Telemetry object containing current state information + """ + click.echo("\n" + "=" * 60) + click.echo("RELAY") + click.echo("=" * 60) + + relay_data: dict[Any, Any] = {**dict(relay), **dict(telemetry)} if telemetry else dict(relay) + for attr_name, value in relay_data.items(): + if attr_name == "state": + value = RelayState(value).pretty() + elif attr_name == "type": + value = RelayType(value).pretty() + elif attr_name == "function": + value = RelayFunction(value).pretty() + elif attr_name == "why_on": + value = RelayWhyOn(value).pretty() + elif isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/sensors.py b/pyomnilogic_local/cli/get/sensors.py new file mode 100644 index 0000000..8ceab42 --- /dev/null +++ b/pyomnilogic_local/cli/get/sensors.py @@ -0,0 +1,74 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from typing import Any + +import click + +from pyomnilogic_local.models.mspconfig import ( + MSPConfig, + MSPSensor, +) +from pyomnilogic_local.omnitypes import ( + SensorType, + SensorUnits, +) + + +@click.command() +@click.pass_context +def sensors(ctx: click.Context) -> None: + """List all sensors and their current settings. + + Displays information about all sensors including their system IDs, names, + sensor type, and units. + + Example: + omnilogic get sensors + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + + sensors_found = False + + # Check for sensors in the backyard + if mspconfig.backyard.sensor: + for sensor in mspconfig.backyard.sensor: + sensors_found = True + _print_sensor_info(sensor) + + # Check for sensors in Bodies of Water + if mspconfig.backyard.bow: + for bow in mspconfig.backyard.bow: + if bow.sensor: + for sensor in bow.sensor: + sensors_found = True + _print_sensor_info(sensor) + + if not sensors_found: + click.echo("No sensors found in the system configuration.") + + +def _print_sensor_info(sensor: MSPSensor) -> None: + """Format and print sensor information in a nice table format. + + Args: + sensor: Sensor object from MSPConfig with attributes to display + """ + click.echo("\n" + "=" * 60) + click.echo("SENSOR") + click.echo("=" * 60) + + sensor_data: dict[Any, Any] = dict(sensor) + for attr_name, value in sensor_data.items(): + if attr_name == "equip_type": + value = SensorType(value).pretty() + elif attr_name == "units": + value = SensorUnits(value).pretty() + elif isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/valves.py b/pyomnilogic_local/cli/get/valves.py index cfce99a..bad1fd3 100644 --- a/pyomnilogic_local/cli/get/valves.py +++ b/pyomnilogic_local/cli/get/valves.py @@ -31,6 +31,8 @@ def valves(ctx: click.Context) -> None: Valve actuators control physical valves for features like waterfalls, fountains, and other water features. + Valves will also show under the output of `get relays` as they are a type of relay. + Example: omnilogic get valves """ From 8e53c376d925acca597461f961578882a19d709b Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:07:32 -0600 Subject: [PATCH 43/61] feat: add support for modifying schedules --- pyomnilogic_local/api/api.py | 2 - pyomnilogic_local/cli/get/commands.py | 2 + pyomnilogic_local/cli/get/schedules.py | 66 +++++++++ pyomnilogic_local/models/mspconfig.py | 40 ++++- pyomnilogic_local/omnilogic.py | 19 +-- pyomnilogic_local/omnitypes.py | 15 +- pyomnilogic_local/schedule.py | 198 +++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 pyomnilogic_local/cli/get/schedules.py create mode 100644 pyomnilogic_local/schedule.py diff --git a/pyomnilogic_local/api/api.py b/pyomnilogic_local/api/api.py index 694f93a..afa0a88 100644 --- a/pyomnilogic_local/api/api.py +++ b/pyomnilogic_local/api/api.py @@ -705,9 +705,7 @@ async def async_edit_schedule( Args: equipment_id (int): The schedule's system ID (schedule-system-id from MSPConfig), NOT the equipment-id. - This identifies which schedule to edit. data (int): The data value for the schedule action (e.g., 50 for 50% speed, 1 for on, 0 for off). - Maps to the 'data' field in the schedule and is passed to the action. action_id (int): The action/event ID that will be executed (e.g., 164 for SetUIEquipmentCmd). Maps to the 'event' field in the schedule. Common values: - 164: SetUIEquipmentCmd (turn equipment on/off or set speed) diff --git a/pyomnilogic_local/cli/get/commands.py b/pyomnilogic_local/cli/get/commands.py index 37c3f6a..b2a34b2 100644 --- a/pyomnilogic_local/cli/get/commands.py +++ b/pyomnilogic_local/cli/get/commands.py @@ -14,6 +14,7 @@ from pyomnilogic_local.cli.get.lights import lights from pyomnilogic_local.cli.get.pumps import pumps from pyomnilogic_local.cli.get.relays import relays +from pyomnilogic_local.cli.get.schedules import schedules from pyomnilogic_local.cli.get.sensors import sensors from pyomnilogic_local.cli.get.valves import valves @@ -42,5 +43,6 @@ def get(ctx: click.Context) -> None: get.add_command(lights) get.add_command(pumps) get.add_command(relays) +get.add_command(schedules) get.add_command(sensors) get.add_command(valves) diff --git a/pyomnilogic_local/cli/get/schedules.py b/pyomnilogic_local/cli/get/schedules.py new file mode 100644 index 0000000..0c516d7 --- /dev/null +++ b/pyomnilogic_local/cli/get/schedules.py @@ -0,0 +1,66 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from typing import Any + +import click + +from pyomnilogic_local.models.mspconfig import ( + MSPConfig, + MSPSchedule, +) + + +@click.command() +@click.pass_context +def schedules(ctx: click.Context) -> None: + """List all schedules and their current settings. + + Displays information about all schedules including their system IDs, names, + current state, and icon IDs. + + Example: + omnilogic get schedules + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + + schedules_found = False + + # Check for schedules at the top level + if mspconfig.schedules: + for schedule in mspconfig.schedules: + schedules_found = True + _print_schedule_info(schedule) + + if not schedules_found: + click.echo("No schedules found in the system configuration.") + + +def _print_schedule_info(schedule: MSPSchedule) -> None: + """Format and print schedule information in a nice table format. + + Args: + schedule: Schedule object from MSPConfig with attributes to display + """ + click.echo("\n" + "=" * 60) + click.echo("SCHEDULE") + click.echo("=" * 60) + + schedule_data: dict[Any, Any] = dict(schedule) + for attr_name, value in schedule_data.items(): + if attr_name == "days_active_raw": + # Skip raw bitmask field + continue + if attr_name == "event": + value = value.pretty() + # value = GroupState(value).pretty() + if isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + # Days Active is a computed property, so it's not in the dict representation + click.echo(f"{'Days Active':20} : {schedule.days_active}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 3f9a58b..eab4f7a 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -14,6 +14,7 @@ ConfigDict, Field, ValidationError, + computed_field, model_validator, ) from xmltodict import parse as xml_parse @@ -33,12 +34,14 @@ FilterType, HeaterType, LightShows, + MessageType, OmniType, PentairShow, PumpFunction, PumpType, RelayFunction, RelayType, + ScheduleDaysActive, SensorType, SensorUnits, ZodiacShow, @@ -364,10 +367,36 @@ class MSPBackyard(OmniBase): class MSPSchedule(OmniBase): omni_type: OmniType = OmniType.SCHEDULE - system_id: int = Field(alias="schedule-system-id") bow_id: int = Field(alias="bow-system-id") # pyright: ignore[reportGeneralTypeIssues] equipment_id: int = Field(alias="equipment-id") + system_id: int = Field(alias="schedule-system-id") + event: MessageType = Field(alias="event") + data: int = Field(alias="data") enabled: bool = Field() + start_minute: int = Field(alias="start-minute") + start_hour: int = Field(alias="start-hour") + end_minute: int = Field(alias="end-minute") + end_hour: int = Field(alias="end-hour") + days_active_raw: int = Field(alias="days-active") + recurring: bool = Field(alias="recurring") + + @computed_field # type: ignore[prop-decorator] + @property + def days_active(self) -> list[str]: + """Decode days_active_raw bitmask into a list of active day names. + + Returns: + List of active day names as strings + + Example: + >>> schedule.days_active + ['Monday', 'Wednesday', 'Friday'] + """ + + flags = ScheduleDaysActive(self.days_active_raw) + final_flags = [flag.name for flag in ScheduleDaysActive if flags & flag and flag.name is not None] + + return final_flags type MSPEquipmentType = ( @@ -397,6 +426,7 @@ class MSPConfig(BaseModel): system: MSPSystem = Field(alias="System") backyard: MSPBackyard = Field(alias="Backyard") groups: list[MSPGroup] | None = None + schedules: list[MSPSchedule] | None = None def __init__(self, **data: Any) -> None: # Extract groups from the Groups container if present @@ -407,6 +437,14 @@ def __init__(self, **data: Any) -> None: if group_data is not None: data["groups"] = [MSPGroup.model_validate(g) for g in group_data] + # Extract schedules from the Schedules container if present + schedule_data: dict[Any, Any] | None = None + if (schedules_data := data.get("Schedules", None)) is not None: + schedule_data = schedules_data.get("sche", None) + + if schedule_data is not None: + data["schedules"] = [MSPSchedule.model_validate(s) for s in schedule_data] + super().__init__(**data) @staticmethod diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index ff5a0bf..b141c0d 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -19,6 +19,7 @@ from pyomnilogic_local.models import MSPConfig, Telemetry from pyomnilogic_local.pump import Pump from pyomnilogic_local.relay import Relay +from pyomnilogic_local.schedule import Schedule from pyomnilogic_local.sensor import Sensor from pyomnilogic_local.system import System @@ -32,6 +33,7 @@ class OmniLogic: system: System backyard: Backyard groups: EquipmentDict[Group] + schedules: EquipmentDict[Schedule] _mspconfig_last_updated: float = 0.0 _telemetry_last_updated: float = 0.0 @@ -140,18 +142,16 @@ def _update_equipment(self) -> None: self.backyard = Backyard(self, self.mspconfig.backyard, self.telemetry) # Update groups - # groups_list: list[Group] = [] - # if self.mspconfig.groups is not None: - # for group_config in self.mspconfig.groups: - # group_telemetry = self.telemetry.get_telem_by_systemid(group_config.system_id) - # if (group_telemetry := self.telemetry.get_telem_by_systemid(group_config.system_id)) is not None: - # groups_list.append(Group(self, group_config, self.telemetry)) - if self.mspconfig.groups is None: self.groups = EquipmentDict() - return + else: + self.groups = EquipmentDict([Group(self, group_, self.telemetry) for group_ in self.mspconfig.groups]) - self.groups = EquipmentDict([Group(self, group_, self.telemetry) for group_ in self.mspconfig.groups]) + # Update schedules + if self.mspconfig.schedules is None: + self.schedules = EquipmentDict() + else: + self.schedules = EquipmentDict([Schedule(self, schedule_, self.telemetry) for schedule_ in self.mspconfig.schedules]) # Equipment discovery properties @property @@ -309,6 +309,7 @@ def get_equipment_by_id(self, system_id: int) -> OmniEquipment[Any, Any] | None: all_equipment.extend(self.all_csads.values()) all_equipment.extend(self.all_csad_equipment.values()) all_equipment.extend(self.groups.values()) + all_equipment.extend(self.schedules.values()) for equipment in all_equipment: if equipment.system_id == system_id: diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index de5fac8..23ab294 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -1,10 +1,10 @@ -from enum import Enum, Flag, IntEnum, StrEnum, auto +from enum import Flag, IntEnum, StrEnum, auto from .util import PrettyEnum # OmniAPI Enums -class MessageType(Enum): +class MessageType(IntEnum, PrettyEnum): XML_ACK = 0000 REQUEST_CONFIGURATION = 1 SET_FILTER_SPEED = 9 @@ -555,3 +555,14 @@ class SensorUnits(StrEnum, PrettyEnum): class ValveActuatorState(IntEnum, PrettyEnum): OFF = 0 ON = 1 + + +# Schedules +class ScheduleDaysActive(Flag, PrettyEnum): + MONDAY = 1 << 0 + TUESDAY = 1 << 1 + WEDNESDAY = 1 << 2 + THURSDAY = 1 << 3 + FRIDAY = 1 << 4 + SATURDAY = 1 << 5 + SUNDAY = 1 << 6 diff --git a/pyomnilogic_local/schedule.py b/pyomnilogic_local/schedule.py new file mode 100644 index 0000000..19de26d --- /dev/null +++ b/pyomnilogic_local/schedule.py @@ -0,0 +1,198 @@ +from typing import TYPE_CHECKING, Any + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.models.mspconfig import MSPSchedule +from pyomnilogic_local.models.telemetry import Telemetry +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic + + +class Schedule(OmniEquipment[MSPSchedule, None]): + """Represents a schedule in the OmniLogic system. + + Schedules control automatic timing of equipment operations. Each schedule defines + when equipment should turn on/off or change state, what days of the week it should + run, and whether it should repeat. + + Attributes: + mspconfig: Configuration data for this schedule from MSP XML + telemetry: None (schedules do not have telemetry data) + + Properties: + bow_id: The Body of Water ID this schedule belongs to + equipment_id: The equipment system ID controlled by this schedule + controlled_equipment: The actual equipment instance controlled by this schedule + event: The MessageType/action that will be executed + data: The data value for the action (e.g., speed, on/off state) + enabled: Whether the schedule is currently enabled + start_hour: Hour to start (0-23) + start_minute: Minute to start (0-59) + end_hour: Hour to end (0-23) + end_minute: Minute to end (0-59) + days_active_raw: Bitmask of active days (1=Mon, 2=Tue, 4=Wed, etc.) + days_active: List of active day names (e.g., ['Monday', 'Wednesday']) + recurring: Whether the schedule repeats + + Control Methods: + turn_on(): Enable the schedule + turn_off(): Disable the schedule + + Example: + >>> omni = OmniLogic(...) + >>> await omni.connect() + >>> + >>> # Access schedules (when implemented in OmniLogic) + >>> schedule = omni.schedules[15] # Access by system_id + >>> + >>> # Check schedule details + >>> print(f"Controls equipment ID: {schedule.equipment_id}") + >>> print(f"Runs on: {', '.join(schedule.days_active)}") + >>> print(f"Time: {schedule.start_hour}:{schedule.start_minute:02d} - {schedule.end_hour}:{schedule.end_minute:02d}") + >>> print(f"Enabled: {schedule.enabled}") + >>> + >>> # Control schedule + >>> await schedule.turn_on() # Enable the schedule + >>> await schedule.turn_off() # Disable the schedule + + Note: + - Schedules do not have telemetry; state is only in configuration + - Turning on/off a schedule only changes its enabled state + - All other schedule parameters (timing, days, equipment) remain unchanged + - The schedule-system-id is used to identify which schedule to edit + """ + + mspconfig: MSPSchedule + telemetry: None + + def __init__(self, omni: "OmniLogic", mspconfig: MSPSchedule, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def equipment_id(self) -> int: + """Returns the equipment ID controlled by this schedule.""" + return self.mspconfig.equipment_id + + @property + def event(self) -> int: + """Returns the event/action ID that will be executed.""" + return self.mspconfig.event.value + + @property + def data(self) -> int: + """Returns the data value for the scheduled action.""" + return self.mspconfig.data + + @property + def enabled(self) -> bool: + """Returns whether the schedule is currently enabled.""" + return self.mspconfig.enabled + + @property + def start_hour(self) -> int: + """Returns the hour the schedule starts (0-23).""" + return self.mspconfig.start_hour + + @property + def start_minute(self) -> int: + """Returns the minute the schedule starts (0-59).""" + return self.mspconfig.start_minute + + @property + def end_hour(self) -> int: + """Returns the hour the schedule ends (0-23).""" + return self.mspconfig.end_hour + + @property + def end_minute(self) -> int: + """Returns the minute the schedule ends (0-59).""" + return self.mspconfig.end_minute + + @property + def days_active_raw(self) -> int: + """Returns the raw bitmask of active days.""" + return self.mspconfig.days_active_raw + + @property + def days_active(self) -> list[str]: + """Returns a list of active day names.""" + return self.mspconfig.days_active + + @property + def recurring(self) -> bool: + """Returns whether the schedule repeats.""" + return self.mspconfig.recurring + + @property + def controlled_equipment(self) -> OmniEquipment[Any, Any] | None: + """Returns the equipment controlled by this schedule. + + Uses the schedule's equipment_id to dynamically look up the actual + equipment instance from the OmniLogic parent. + + Returns: + The equipment instance controlled by this schedule, or None if not found. + + Example: + >>> schedule = omni.schedules[15] + >>> equipment = schedule.controlled_equipment + >>> if equipment: + ... print(f"This schedule controls: {equipment.name}") + """ + return self._omni.get_equipment_by_id(self.equipment_id) + + @dirties_state() + async def turn_on(self) -> None: + """ + Enable the schedule. + + Sends an edit command with all current schedule parameters but sets + the enabled state to True. + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + """ + if self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn on schedule: system_id is None") + + await self._api.async_edit_schedule( + equipment_id=self.system_id, # This is the schedule-system-id + data=self.data, + action_id=self.event, + start_time_hours=self.start_hour, + start_time_minutes=self.start_minute, + end_time_hours=self.end_hour, + end_time_minutes=self.end_minute, + days_active=self.days_active_raw, + is_enabled=True, # Enable the schedule + recurring=self.recurring, + ) + + @dirties_state() + async def turn_off(self) -> None: + """ + Disable the schedule. + + Sends an edit command with all current schedule parameters but sets + the enabled state to False. + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + """ + if self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn off schedule: system_id is None") + + await self._api.async_edit_schedule( + equipment_id=self.system_id, # This is the schedule-system-id + data=self.data, + action_id=self.event, + start_time_hours=self.start_hour, + start_time_minutes=self.start_minute, + end_time_hours=self.end_hour, + end_time_minutes=self.end_minute, + days_active=self.days_active_raw, + is_enabled=False, # Disable the schedule + recurring=self.recurring, + ) From 84336925533f0aae6fdcecb20fa0b249c57f4d6b Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:37:32 -0600 Subject: [PATCH 44/61] feat: update copilot_instructions.md --- .github/copilot-instructions.md | 337 ++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 33fa38d..219d8bf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -85,3 +85,340 @@ find_tasks(filter_by="project", filter_value="proj-123") - Keep queries SHORT (2-5 keywords) for better search results - Higher `task_order` = higher priority (0-100) - Tasks should be 30 min - 4 hours of work + +--- + +# GitHub Copilot Instructions + +## Priority Guidelines + +When generating code for this repository: + +1. **Version Compatibility**: Always use Python 3.12+ features (requires-python = ">=3.12,<4.0.0") +2. **Type Safety**: This is a strictly typed codebase - use type hints, follow mypy strict mode, and leverage Pydantic for validation +3. **Codebase Patterns**: Scan existing code for established patterns before generating new code +4. **Architectural Consistency**: Maintain the layered architecture with clear separation between API, models, and equipment classes +5. **Code Quality**: Prioritize maintainability, type safety, and testability in all generated code + +## Technology Stack + +### Python Version +- **Required**: Python 3.12+ +- **Type Checking**: mypy with strict mode enabled (python_version = "3.13" in config) +- **Use modern Python features**: Pattern matching, typing improvements, exception groups + +### Core Dependencies +- **pydantic**: v2.x - Use for all data validation and models +- **click**: v8.x - CLI framework (use click decorators and commands) +- **xmltodict**: v1.x - XML parsing (used for OmniLogic protocol) + +### Development Tools +- **pytest**: v8.x - Testing framework with async support (pytest-asyncio) +- **black**: Line length 140 - Code formatting +- **isort**: Profile "black" - Import sorting +- **mypy**: Strict mode - Type checking with Pydantic plugin +- **pylint**: Custom configuration - Code linting + +## Code Quality Standards + +### Type Safety & Type Hints +- **MANDATORY**: All functions/methods MUST have complete type annotations +- Use `from __future__ import annotations` for forward references +- Leverage generics extensively (see `OmniEquipment[MSPConfigT, TelemetryT]`) +- Use `TYPE_CHECKING` imports to avoid circular dependencies +- Apply `@overload` for methods with different return types based on parameters +- Use Pydantic models for all data structures requiring validation +- Prefer `str | None` over `Optional[str]` (Python 3.10+ union syntax) + +Example patterns from codebase: +```python +from typing import TYPE_CHECKING, Generic, TypeVar, cast, overload, Literal + +MSPConfigT = TypeVar("MSPConfigT", bound=MSPEquipmentType) +TelemetryT = TypeVar("TelemetryT", bound=TelemetryType | None) + +class OmniEquipment(Generic[MSPConfigT, TelemetryT]): + """Base class with generic parameters for type safety.""" + + @overload + async def async_send_message(self, need_response: Literal[True]) -> str: ... + @overload + async def async_send_message(self, need_response: Literal[False]) -> None: ... + async def async_send_message(self, need_response: bool = False) -> str | None: + """Method with overloads for precise return type inference.""" +``` + +### Naming Conventions +- **Modules**: `snake_case` (e.g., `heater_equip.py`, `colorlogiclight.py`) +- **Classes**: `PascalCase` (e.g., `OmniLogicAPI`, `HeaterEquipment`, `ColorLogicLight`) +- **Functions/Methods**: `snake_case` with async prefix (e.g., `async_get_telemetry`, `turn_on`) +- **Private attributes**: Single underscore prefix (e.g., `_api`, `_omni`, `_validate_temperature`) +- **Type variables**: Descriptive with `T` suffix (e.g., `MSPConfigT`, `TelemetryT`, `OE`) +- **Constants**: `UPPER_SNAKE_CASE` (e.g., `MAX_TEMPERATURE_F`, `XML_NAMESPACE`) +- **Pydantic field aliases**: Use `alias="System-Id"` for XML field names + +### Documentation Style +- **Docstrings**: Google-style docstrings for all public classes and methods +- Include Args, Returns, Raises, and Example sections where applicable +- Document generic parameters clearly +- Provide usage examples in class docstrings +- Currently missing docstrings are disabled in pylint - aim to add them when code stabilizes + +Example from codebase: +```python +def _validate_temperature(temperature: int, param_name: str = "temperature") -> None: + """Validate temperature is within acceptable range. + + Args: + temperature: Temperature value in Fahrenheit. + param_name: Name of the parameter for error messages. + + Raises: + OmniValidationException: If temperature is out of range. + """ +``` + +### Error Handling +- Use custom exception hierarchy (all inherit from `OmniLogicException` or `OmniLogicLocalError`) +- API exceptions: `OmniProtocolException`, `OmniValidationException`, `OmniCommandException` +- Equipment exceptions: `OmniEquipmentNotReadyError`, `OmniEquipmentNotInitializedError` +- Validate inputs early with dedicated `_validate_*` functions +- Provide clear error messages with parameter names and values + +### Async Patterns +- **Prefix async methods**: `async_get_telemetry`, `async_set_heater`, etc. +- Use `asyncio.get_running_loop()` for low-level operations +- Properly manage transport lifecycle (create and close in try/finally) +- Equipment control methods are async and use `@dirties_state` decorator + +## Architectural Patterns + +### Equipment Hierarchy +All equipment classes inherit from `OmniEquipment[MSPConfigT, TelemetryT]`: +- First generic parameter: MSP config type (e.g., `MSPRelay`, `MSPVirtualHeater`) +- Second generic parameter: Telemetry type or `None` (e.g., `TelemetryRelay`, `None`) +- Access parent controller via `self._omni` +- Access API via `self._api` property +- Store child equipment in `self.child_equipment: dict[int, OmniEquipment]` + +### State Management +- Use `@dirties_state(mspconfig=False, telemetry=True)` decorator on control methods +- Marks state as needing refresh after commands +- Default: marks telemetry dirty, not mspconfig +- Users call `await omni.refresh()` to update state + +Example: +```python +@dirties_state(telemetry=True) +async def turn_on(self) -> None: + """Turn on equipment and mark telemetry as needing refresh.""" + await self._api.async_set_equipment(...) +``` + +### Pydantic Models +- All configuration models inherit from `OmniBase` (which inherits from `BaseModel`) +- Use `Field(alias="XML-Name")` for XML field mapping +- Implement `_YES_NO_FIELDS` class variable for automatic "yes"/"no" to bool conversion +- Use `@computed_field` for derived properties +- Implement `model_validator(mode="before")` for custom preprocessing +- Use `ConfigDict(from_attributes=True)` for attribute-based initialization + +### Collections +- `EquipmentDict[OE]`: Type-safe dictionary for equipment access by name or system_id +- `EffectsCollection[E]`: Type-safe collection for light effects +- Support both indexing (`dict[key]`) and attribute access (`.key` via `__getattr__`) + +## Testing Approach + +### Test Structure +- Use **table-driven tests** with `pytest-subtests` for validation functions +- Organize tests into clear sections with comment headers +- Use helper functions for XML parsing and assertions (e.g., `_find_elem`, `_find_param`) +- Test both success and failure cases comprehensively + +Example pattern: +```python +def test_validate_temperature(subtests: SubTests) -> None: + """Test temperature validation with various inputs using table-driven approach.""" + test_cases = [ + # (temperature, param_name, should_pass, description) + (MIN_TEMPERATURE_F, "temp", True, "minimum valid temperature"), + (MAX_TEMPERATURE_F + 1, "temp", False, "above maximum temperature"), + ("80", "temp", False, "string instead of int"), + ] + + for temperature, param_name, should_pass, description in test_cases: + with subtests.test(msg=description, temperature=temperature): + if should_pass: + _validate_temperature(temperature, param_name) + else: + with pytest.raises(OmniValidationException): + _validate_temperature(temperature, param_name) +``` + +### Test Coverage +- Unit tests for validation functions +- Integration tests for API message generation +- Mock external dependencies (transport, protocol) +- Test both async and sync code paths +- Aim for 80%+ coverage (pytest-cov configured) + +### Test Naming +- Prefix all test functions with `test_` +- Use descriptive names: `test_async_set_heater_generates_valid_xml` +- For async tests, use `async def test_...` with pytest-asyncio + +## Code Formatting & Linting + +### Line Length & Formatting +- **Maximum line length**: 140 characters (black, pylint, ruff configured) +- Use black for automatic formatting +- Use isort with black profile for import organization +- Prefer explicit over implicit line continuations + +### Import Organization +- Standard library imports first +- Third-party imports second +- Local imports third +- Use `from __future__ import annotations` at top when needed +- Group `from typing import ...` statements +- Use `TYPE_CHECKING` guard for circular dependency imports + +Example: +```python +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +from pydantic import BaseModel, Field + +from pyomnilogic_local.models import MSPConfig +from pyomnilogic_local.omnitypes import HeaterMode + +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic +``` + +### Pylint Configuration +- Py-version: 3.13 +- Many rules disabled for pragmatic development (see pyproject.toml) +- Focus on: type safety, useless suppressions, symbolic messages +- Docstrings currently disabled until codebase stabilizes + +## Project-Specific Patterns + +### Equipment Properties +All equipment classes expose standard properties: +```python +@property +def bow_id(self) -> int | None: + """The body of water ID this equipment belongs to.""" + return self.mspconfig.bow_id + +@property +def name(self) -> str | None: + """The name of the equipment.""" + return self.mspconfig.name + +@property +def is_ready(self) -> bool: + """Whether equipment can accept commands.""" + return self._omni.backyard.state == BackyardState.READY +``` + +### API Method Patterns +API methods follow consistent patterns: +1. Validate inputs with `_validate_*` helper functions +2. Build XML message using ElementTree +3. Call `async_send_message` with appropriate message type +4. Parse response if needed +5. Return strongly typed result + +### Equipment Control Methods +Equipment control methods: +1. Check `is_ready` property before executing +2. Raise `OmniEquipmentNotReadyError` if not ready +3. Use `@dirties_state` decorator +4. Call appropriate API method +5. Return `None` (state updated via refresh) + +## Version Control & Semantic Versioning + +- Follow Semantic Versioning (currently v0.19.0) +- Version defined in `pyproject.toml:project.version` +- Use `python-semantic-release` for automated versioning +- Main branch: `main` +- CHANGELOG.md is automatically generated by release pipeline + +## General Best Practices + +1. **Consistency First**: Match existing patterns even if they differ from external best practices +2. **Type Safety**: Never compromise on type annotations +3. **Validation**: Validate all inputs at API boundaries +4. **Async/Await**: Use proper async patterns, don't block the event loop +5. **Error Messages**: Include parameter names and actual values in validation errors +6. **Generics**: Leverage Python's generic types for type-safe collections and base classes +7. **Pydantic**: Use for all data validation, XML parsing, and model definitions +8. **Testing**: Write tests before implementation when possible (especially for validation) +9. **Documentation**: Provide examples in docstrings for complex classes +10. **Separation of Concerns**: Keep API layer separate from equipment models + +## Example: Adding New Equipment Type + +When adding a new equipment type, follow this pattern: + +1. **Create Pydantic model** in `models/mspconfig.py`: +```python +class MSPNewEquipment(OmniBase): + _sub_devices: set[str] | None = None + omni_type: Literal[OmniType.NEW_EQUIPMENT] = OmniType.NEW_EQUIPMENT + # Add fields with XML aliases +``` + +2. **Create telemetry model** in `models/telemetry.py` (if applicable): +```python +class TelemetryNewEquipment(BaseModel): + # Add telemetry fields +``` + +3. **Create equipment class** in `new_equipment.py`: +```python +class NewEquipment(OmniEquipment[MSPNewEquipment, TelemetryNewEquipment]): + """New equipment type.""" + + @property + def some_property(self) -> str | None: + """Equipment-specific property.""" + return self.mspconfig.some_field + + @dirties_state(telemetry=True) + async def control_method(self) -> None: + """Control method with state dirtying.""" + if not self.is_ready: + raise OmniEquipmentNotReadyError(...) + await self._api.async_some_command(...) +``` + +4. **Add API method** in `api/api.py`: +```python +async def async_some_command(self, param: int) -> None: + """Send command to equipment. + + Args: + param: Description of parameter. + + Raises: + OmniValidationException: If param is invalid. + """ + _validate_id(param, "param") + # Build and send XML message +``` + +5. **Write tests** in `tests/test_new_equipment.py`: +```python +def test_some_command_generates_valid_xml(subtests: SubTests) -> None: + """Test command XML generation.""" + # Table-driven tests +``` From 285708fbdce291f03cc10af0836783fb400148cf Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:58:01 -0600 Subject: [PATCH 45/61] chore: migrate to from future import annotations for type hints --- pyomnilogic_local/__init__.py | 2 ++ pyomnilogic_local/_base.py | 8 +++++--- pyomnilogic_local/api/__init__.py | 2 ++ pyomnilogic_local/api/constants.py | 6 ++++-- pyomnilogic_local/api/exceptions.py | 3 +++ pyomnilogic_local/api/protocol.py | 2 ++ pyomnilogic_local/backyard.py | 4 +++- pyomnilogic_local/bow.py | 4 +++- pyomnilogic_local/chlorinator.py | 4 +++- pyomnilogic_local/chlorinator_equip.py | 4 +++- pyomnilogic_local/cli/__init__.py | 2 ++ pyomnilogic_local/cli/cli.py | 4 +++- pyomnilogic_local/cli/debug/commands.py | 3 +++ pyomnilogic_local/cli/get/backyard.py | 2 ++ pyomnilogic_local/cli/get/bows.py | 2 ++ pyomnilogic_local/cli/get/chlorinators.py | 2 ++ pyomnilogic_local/cli/get/commands.py | 2 ++ pyomnilogic_local/cli/get/csads.py | 2 ++ pyomnilogic_local/cli/get/filters.py | 2 ++ pyomnilogic_local/cli/get/groups.py | 2 ++ pyomnilogic_local/cli/get/heaters.py | 2 ++ pyomnilogic_local/cli/get/lights.py | 2 ++ pyomnilogic_local/cli/get/pumps.py | 2 ++ pyomnilogic_local/cli/get/relays.py | 2 ++ pyomnilogic_local/cli/get/schedules.py | 2 ++ pyomnilogic_local/cli/get/sensors.py | 2 ++ pyomnilogic_local/cli/get/valves.py | 2 ++ pyomnilogic_local/cli/pcap_utils.py | 2 ++ pyomnilogic_local/cli/utils.py | 2 ++ pyomnilogic_local/collections.py | 2 ++ pyomnilogic_local/colorlogiclight.py | 4 +++- pyomnilogic_local/csad.py | 4 +++- pyomnilogic_local/csad_equip.py | 4 +++- pyomnilogic_local/decorators.py | 2 ++ pyomnilogic_local/filter.py | 2 ++ pyomnilogic_local/groups.py | 4 +++- pyomnilogic_local/heater.py | 4 +++- pyomnilogic_local/heater_equip.py | 4 +++- pyomnilogic_local/models/__init__.py | 2 ++ pyomnilogic_local/omnilogic.py | 2 ++ pyomnilogic_local/omnitypes.py | 2 ++ pyomnilogic_local/pump.py | 2 ++ pyomnilogic_local/relay.py | 4 +++- pyomnilogic_local/schedule.py | 4 +++- pyomnilogic_local/sensor.py | 4 +++- pyomnilogic_local/system.py | 2 ++ pyomnilogic_local/util.py | 2 ++ 47 files changed, 115 insertions(+), 19 deletions(-) diff --git a/pyomnilogic_local/__init__.py b/pyomnilogic_local/__init__.py index 8c2f6b5..29443a6 100644 --- a/pyomnilogic_local/__init__.py +++ b/pyomnilogic_local/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .collections import EffectsCollection, LightEffectsCollection from .omnilogic import OmniLogic from .util import ( diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py index 3f2ed5a..ebf7b06 100644 --- a/pyomnilogic_local/_base.py +++ b/pyomnilogic_local/_base.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import TYPE_CHECKING, Generic, TypeVar, cast @@ -58,9 +60,9 @@ class OmniEquipment(Generic[MSPConfigT, TelemetryT]): telemetry: TelemetryT # Use a forward reference for the type hint to avoid issues with self-referential generics - child_equipment: dict[int, "OmniEquipment[MSPConfigT, TelemetryT]"] + child_equipment: dict[int, OmniEquipment[MSPConfigT, TelemetryT]] - def __init__(self, omni: "OmniLogic", mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: """Initialize the equipment with configuration and telemetry data. Args: @@ -73,7 +75,7 @@ def __init__(self, omni: "OmniLogic", mspconfig: MSPConfigT, telemetry: Telemetr self.update(mspconfig, telemetry) @property - def _api(self) -> "OmniLogicAPI": + def _api(self) -> OmniLogicAPI: """Access the OmniLogic API through the parent controller.""" return self._omni._api # pylint: disable=protected-access diff --git a/pyomnilogic_local/api/__init__.py b/pyomnilogic_local/api/__init__.py index 938551e..3214c4f 100644 --- a/pyomnilogic_local/api/__init__.py +++ b/pyomnilogic_local/api/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .api import OmniLogicAPI __all__ = [ diff --git a/pyomnilogic_local/api/constants.py b/pyomnilogic_local/api/constants.py index 912b677..7d0c76e 100644 --- a/pyomnilogic_local/api/constants.py +++ b/pyomnilogic_local/api/constants.py @@ -1,6 +1,8 @@ -"""Constants for the OmniLogic protocol implementation.""" +"""Constants for the OmniLogic API.""" -# Protocol Message Constants +from __future__ import annotations + +# Protocol Configuration PROTOCOL_HEADER_SIZE = 24 # Size of the message header in bytes PROTOCOL_HEADER_FORMAT = "!LQ4sLBBBB" # struct format for header PROTOCOL_VERSION = "1.19" # Current protocol version diff --git a/pyomnilogic_local/api/exceptions.py b/pyomnilogic_local/api/exceptions.py index b50917a..f6d18dc 100644 --- a/pyomnilogic_local/api/exceptions.py +++ b/pyomnilogic_local/api/exceptions.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + class OmniLogicException(Exception): """Base exception for all OmniLogic errors.""" diff --git a/pyomnilogic_local/api/protocol.py b/pyomnilogic_local/api/protocol.py index d9d42c2..5c7c43d 100644 --- a/pyomnilogic_local/api/protocol.py +++ b/pyomnilogic_local/api/protocol.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import logging import random diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py index 22c610e..b958db6 100644 --- a/pyomnilogic_local/backyard.py +++ b/pyomnilogic_local/backyard.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import TYPE_CHECKING @@ -99,7 +101,7 @@ class Backyard(OmniEquipment[MSPBackyard, TelemetryBackyard]): relays: EquipmentDict[Relay] = EquipmentDict() sensors: EquipmentDict[Sensor] = EquipmentDict() - def __init__(self, omni: "OmniLogic", mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @property diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index 4664854..3917145 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment @@ -135,7 +137,7 @@ class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): chlorinator: Chlorinator | None = None csads: EquipmentDict[CSAD] = EquipmentDict() - def __init__(self, omni: "OmniLogic", mspconfig: MSPBoW, telemetry: Telemetry) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPBoW, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) def __repr__(self) -> str: diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py index a98c8a4..8caff57 100644 --- a/pyomnilogic_local/chlorinator.py +++ b/pyomnilogic_local/chlorinator.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment @@ -42,7 +44,7 @@ class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): telemetry: TelemetryChlorinator chlorinator_equipment: EquipmentDict[ChlorinatorEquipment] = EquipmentDict() - def __init__(self, omni: "OmniLogic", mspconfig: MSPChlorinator, telemetry: Telemetry) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPChlorinator, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) def _update_equipment(self, mspconfig: MSPChlorinator, telemetry: Telemetry | None) -> None: diff --git a/pyomnilogic_local/chlorinator_equip.py b/pyomnilogic_local/chlorinator_equip.py index fe4bbef..48e29f7 100644 --- a/pyomnilogic_local/chlorinator_equip.py +++ b/pyomnilogic_local/chlorinator_equip.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Literal from pyomnilogic_local._base import OmniEquipment @@ -61,7 +63,7 @@ class ChlorinatorEquipment(OmniEquipment[MSPChlorinatorEquip, None]): mspconfig: MSPChlorinatorEquip telemetry: None - def __init__(self, omni: "OmniLogic", mspconfig: MSPChlorinatorEquip, telemetry: Telemetry | None) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPChlorinatorEquip, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @property diff --git a/pyomnilogic_local/cli/__init__.py b/pyomnilogic_local/cli/__init__.py index e07f453..7831644 100644 --- a/pyomnilogic_local/cli/__init__.py +++ b/pyomnilogic_local/cli/__init__.py @@ -4,6 +4,8 @@ OmniLogic and OmniHub pool controllers. """ +from __future__ import annotations + from pyomnilogic_local.cli.utils import ensure_connection __all__ = ["ensure_connection"] diff --git a/pyomnilogic_local/cli/cli.py b/pyomnilogic_local/cli/cli.py index 164e683..0357d40 100644 --- a/pyomnilogic_local/cli/cli.py +++ b/pyomnilogic_local/cli/cli.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import click from pyomnilogic_local.cli.debug import commands as debug from pyomnilogic_local.cli.get import commands as get -@click.group(invoke_without_command=True) +@click.group() @click.pass_context @click.option("--host", default="127.0.0.1", help="Hostname or IP address of OmniLogic system (default: 127.0.0.1)") def entrypoint(ctx: click.Context, host: str) -> None: diff --git a/pyomnilogic_local/cli/debug/commands.py b/pyomnilogic_local/cli/debug/commands.py index d392936..01430ac 100644 --- a/pyomnilogic_local/cli/debug/commands.py +++ b/pyomnilogic_local/cli/debug/commands.py @@ -1,5 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" + +from __future__ import annotations + import asyncio from pathlib import Path diff --git a/pyomnilogic_local/cli/get/backyard.py b/pyomnilogic_local/cli/get/backyard.py index ec95af0..0b57d4b 100644 --- a/pyomnilogic_local/cli/get/backyard.py +++ b/pyomnilogic_local/cli/get/backyard.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any import click diff --git a/pyomnilogic_local/cli/get/bows.py b/pyomnilogic_local/cli/get/bows.py index d048db7..605d43b 100644 --- a/pyomnilogic_local/cli/get/bows.py +++ b/pyomnilogic_local/cli/get/bows.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any import click diff --git a/pyomnilogic_local/cli/get/chlorinators.py b/pyomnilogic_local/cli/get/chlorinators.py index a5c139d..1777225 100644 --- a/pyomnilogic_local/cli/get/chlorinators.py +++ b/pyomnilogic_local/cli/get/chlorinators.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any, cast import click diff --git a/pyomnilogic_local/cli/get/commands.py b/pyomnilogic_local/cli/get/commands.py index b2a34b2..860ad9d 100644 --- a/pyomnilogic_local/cli/get/commands.py +++ b/pyomnilogic_local/cli/get/commands.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + import click from pyomnilogic_local.cli import ensure_connection diff --git a/pyomnilogic_local/cli/get/csads.py b/pyomnilogic_local/cli/get/csads.py index df018c5..2274f6f 100644 --- a/pyomnilogic_local/cli/get/csads.py +++ b/pyomnilogic_local/cli/get/csads.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any, cast import click diff --git a/pyomnilogic_local/cli/get/filters.py b/pyomnilogic_local/cli/get/filters.py index f519dfe..010a2a5 100644 --- a/pyomnilogic_local/cli/get/filters.py +++ b/pyomnilogic_local/cli/get/filters.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any import click diff --git a/pyomnilogic_local/cli/get/groups.py b/pyomnilogic_local/cli/get/groups.py index 26a77e4..776442f 100644 --- a/pyomnilogic_local/cli/get/groups.py +++ b/pyomnilogic_local/cli/get/groups.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any, cast import click diff --git a/pyomnilogic_local/cli/get/heaters.py b/pyomnilogic_local/cli/get/heaters.py index 83b5731..52730b4 100644 --- a/pyomnilogic_local/cli/get/heaters.py +++ b/pyomnilogic_local/cli/get/heaters.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any import click diff --git a/pyomnilogic_local/cli/get/lights.py b/pyomnilogic_local/cli/get/lights.py index e6bd69a..9d0bf0f 100644 --- a/pyomnilogic_local/cli/get/lights.py +++ b/pyomnilogic_local/cli/get/lights.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any, cast import click diff --git a/pyomnilogic_local/cli/get/pumps.py b/pyomnilogic_local/cli/get/pumps.py index f1cdb48..189ee36 100644 --- a/pyomnilogic_local/cli/get/pumps.py +++ b/pyomnilogic_local/cli/get/pumps.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any import click diff --git a/pyomnilogic_local/cli/get/relays.py b/pyomnilogic_local/cli/get/relays.py index a61d585..9cff405 100644 --- a/pyomnilogic_local/cli/get/relays.py +++ b/pyomnilogic_local/cli/get/relays.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any import click diff --git a/pyomnilogic_local/cli/get/schedules.py b/pyomnilogic_local/cli/get/schedules.py index 0c516d7..02e3633 100644 --- a/pyomnilogic_local/cli/get/schedules.py +++ b/pyomnilogic_local/cli/get/schedules.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any import click diff --git a/pyomnilogic_local/cli/get/sensors.py b/pyomnilogic_local/cli/get/sensors.py index 8ceab42..6b9e208 100644 --- a/pyomnilogic_local/cli/get/sensors.py +++ b/pyomnilogic_local/cli/get/sensors.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any import click diff --git a/pyomnilogic_local/cli/get/valves.py b/pyomnilogic_local/cli/get/valves.py index bad1fd3..6cf3c1d 100644 --- a/pyomnilogic_local/cli/get/valves.py +++ b/pyomnilogic_local/cli/get/valves.py @@ -1,6 +1,8 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + from typing import Any import click diff --git a/pyomnilogic_local/cli/pcap_utils.py b/pyomnilogic_local/cli/pcap_utils.py index eec688e..d828949 100644 --- a/pyomnilogic_local/cli/pcap_utils.py +++ b/pyomnilogic_local/cli/pcap_utils.py @@ -5,6 +5,8 @@ reassembly and payload decompression. """ +from __future__ import annotations + import xml.etree.ElementTree as ET import zlib from collections import defaultdict diff --git a/pyomnilogic_local/cli/utils.py b/pyomnilogic_local/cli/utils.py index 1baee1a..419beeb 100644 --- a/pyomnilogic_local/cli/utils.py +++ b/pyomnilogic_local/cli/utils.py @@ -4,6 +4,8 @@ accessing controller data within the Click context. """ +from __future__ import annotations + import asyncio from typing import Literal, overload diff --git a/pyomnilogic_local/collections.py b/pyomnilogic_local/collections.py index 003f545..d1feed3 100644 --- a/pyomnilogic_local/collections.py +++ b/pyomnilogic_local/collections.py @@ -1,5 +1,7 @@ """Custom collection types for OmniLogic equipment management.""" +from __future__ import annotations + import logging from collections import Counter from collections.abc import Iterator diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index cc54347..e56e059 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import TYPE_CHECKING @@ -118,7 +120,7 @@ class ColorLogicLight(OmniEquipment[MSPColorLogicLight, TelemetryColorLogicLight mspconfig: MSPColorLogicLight telemetry: TelemetryColorLogicLight - def __init__(self, omni: "OmniLogic", mspconfig: MSPColorLogicLight, telemetry: Telemetry) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPColorLogicLight, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @property diff --git a/pyomnilogic_local/csad.py b/pyomnilogic_local/csad.py index 0d74b97..120e5bf 100644 --- a/pyomnilogic_local/csad.py +++ b/pyomnilogic_local/csad.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment @@ -41,7 +43,7 @@ class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]): telemetry: TelemetryCSAD csad_equipment: EquipmentDict[CSADEquipment] = EquipmentDict() - def __init__(self, omni: "OmniLogic", mspconfig: MSPCSAD, telemetry: Telemetry) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPCSAD, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) def _update_equipment(self, mspconfig: MSPCSAD, telemetry: Telemetry | None) -> None: diff --git a/pyomnilogic_local/csad_equip.py b/pyomnilogic_local/csad_equip.py index 64022d9..df07688 100644 --- a/pyomnilogic_local/csad_equip.py +++ b/pyomnilogic_local/csad_equip.py @@ -1,5 +1,7 @@ """CSAD equipment classes for Omnilogic.""" +from __future__ import annotations + from typing import TYPE_CHECKING, Literal from pyomnilogic_local._base import OmniEquipment @@ -56,7 +58,7 @@ class CSADEquipment(OmniEquipment[MSPCSADEquip, None]): mspconfig: MSPCSADEquip telemetry: None - def __init__(self, omni: "OmniLogic", mspconfig: MSPCSADEquip, telemetry: Telemetry | None) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPCSADEquip, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @property diff --git a/pyomnilogic_local/decorators.py b/pyomnilogic_local/decorators.py index cd8ec77..a0ace9b 100644 --- a/pyomnilogic_local/decorators.py +++ b/pyomnilogic_local/decorators.py @@ -1,5 +1,7 @@ """Decorators for equipment control methods.""" +from __future__ import annotations + import functools import logging from collections.abc import Callable diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py index b3e548f..6992807 100644 --- a/pyomnilogic_local/filter.py +++ b/pyomnilogic_local/filter.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPFilter diff --git a/pyomnilogic_local/groups.py b/pyomnilogic_local/groups.py index 94ba6a3..9c09c51 100644 --- a/pyomnilogic_local/groups.py +++ b/pyomnilogic_local/groups.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment @@ -68,7 +70,7 @@ class Group(OmniEquipment[MSPGroup, TelemetryGroup]): mspconfig: MSPGroup telemetry: TelemetryGroup - def __init__(self, omni: "OmniLogic", mspconfig: MSPGroup, telemetry: Telemetry) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPGroup, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @property diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py index e775886..211eb13 100644 --- a/pyomnilogic_local/heater.py +++ b/pyomnilogic_local/heater.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment @@ -101,7 +103,7 @@ class Heater(OmniEquipment[MSPVirtualHeater, TelemetryVirtualHeater]): telemetry: TelemetryVirtualHeater heater_equipment: EquipmentDict[HeaterEquipment] = EquipmentDict() - def __init__(self, omni: "OmniLogic", mspconfig: MSPVirtualHeater, telemetry: Telemetry) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPVirtualHeater, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) def _update_equipment(self, mspconfig: MSPVirtualHeater, telemetry: Telemetry | None) -> None: diff --git a/pyomnilogic_local/heater_equip.py b/pyomnilogic_local/heater_equip.py index da944ac..69c692b 100644 --- a/pyomnilogic_local/heater_equip.py +++ b/pyomnilogic_local/heater_equip.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment @@ -82,7 +84,7 @@ class HeaterEquipment(OmniEquipment[MSPHeaterEquip, TelemetryHeater]): mspconfig: MSPHeaterEquip telemetry: TelemetryHeater - def __init__(self, omni: "OmniLogic", mspconfig: MSPHeaterEquip, telemetry: Telemetry) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPHeaterEquip, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @property diff --git a/pyomnilogic_local/models/__init__.py b/pyomnilogic_local/models/__init__.py index efd21ba..cd4ba09 100644 --- a/pyomnilogic_local/models/__init__.py +++ b/pyomnilogic_local/models/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .filter_diagnostics import FilterDiagnostics from .mspconfig import MSPConfig, MSPConfigType, MSPEquipmentType from .telemetry import Telemetry, TelemetryType diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index b141c0d..7b927da 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import logging import time diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index 23ab294..203f387 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import Flag, IntEnum, StrEnum, auto from .util import PrettyEnum diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index fa03939..0161152 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.decorators import dirties_state from pyomnilogic_local.models.mspconfig import MSPPump diff --git a/pyomnilogic_local/relay.py b/pyomnilogic_local/relay.py index c793e96..63be424 100644 --- a/pyomnilogic_local/relay.py +++ b/pyomnilogic_local/relay.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment @@ -71,7 +73,7 @@ class Relay(OmniEquipment[MSPRelay, TelemetryRelay]): mspconfig: MSPRelay telemetry: TelemetryRelay - def __init__(self, omni: "OmniLogic", mspconfig: MSPRelay, telemetry: Telemetry) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPRelay, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @property diff --git a/pyomnilogic_local/schedule.py b/pyomnilogic_local/schedule.py index 19de26d..8c96964 100644 --- a/pyomnilogic_local/schedule.py +++ b/pyomnilogic_local/schedule.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Any from pyomnilogic_local._base import OmniEquipment @@ -67,7 +69,7 @@ class Schedule(OmniEquipment[MSPSchedule, None]): mspconfig: MSPSchedule telemetry: None - def __init__(self, omni: "OmniLogic", mspconfig: MSPSchedule, telemetry: Telemetry) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPSchedule, telemetry: Telemetry) -> None: super().__init__(omni, mspconfig, telemetry) @property diff --git a/pyomnilogic_local/sensor.py b/pyomnilogic_local/sensor.py index b747033..9424de4 100644 --- a/pyomnilogic_local/sensor.py +++ b/pyomnilogic_local/sensor.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment @@ -80,7 +82,7 @@ class Sensor(OmniEquipment[MSPSensor, None]): mspconfig: MSPSensor - def __init__(self, omni: "OmniLogic", mspconfig: MSPSensor, telemetry: Telemetry | None) -> None: + def __init__(self, omni: OmniLogic, mspconfig: MSPSensor, telemetry: Telemetry | None) -> None: super().__init__(omni, mspconfig, telemetry) @property diff --git a/pyomnilogic_local/system.py b/pyomnilogic_local/system.py index d569aa7..707dc90 100644 --- a/pyomnilogic_local/system.py +++ b/pyomnilogic_local/system.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pyomnilogic_local.models.mspconfig import MSPSystem diff --git a/pyomnilogic_local/util.py b/pyomnilogic_local/util.py index ab0fce5..26f8e93 100644 --- a/pyomnilogic_local/util.py +++ b/pyomnilogic_local/util.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys from enum import Enum From d8c1371487b2e64ecff397d178f0da49e2614f0a Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:12:29 -0600 Subject: [PATCH 46/61] feat: better handle state updates and control readiness checks --- .github/copilot-instructions.md | 39 ++++++++++----- pyomnilogic_local/bow.py | 13 +++-- pyomnilogic_local/chlorinator.py | 8 +-- pyomnilogic_local/colorlogiclight.py | 28 ++--------- pyomnilogic_local/decorators.py | 71 +++++++++++++++++---------- pyomnilogic_local/filter.py | 23 ++++++--- pyomnilogic_local/groups.py | 6 +-- pyomnilogic_local/heater.py | 14 ++++-- pyomnilogic_local/models/telemetry.py | 2 +- pyomnilogic_local/omnilogic.py | 64 ++++++++++++++---------- pyomnilogic_local/pump.py | 19 ++++--- pyomnilogic_local/relay.py | 6 +-- pyomnilogic_local/schedule.py | 6 +-- pyproject.toml | 20 ++++---- 14 files changed, 183 insertions(+), 136 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 219d8bf..98ab829 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -189,7 +189,7 @@ def _validate_temperature(temperature: int, param_name: str = "temperature") -> - **Prefix async methods**: `async_get_telemetry`, `async_set_heater`, etc. - Use `asyncio.get_running_loop()` for low-level operations - Properly manage transport lifecycle (create and close in try/finally) -- Equipment control methods are async and use `@dirties_state` decorator +- Equipment control methods are async and use `@control_method` decorator ## Architectural Patterns @@ -202,16 +202,19 @@ All equipment classes inherit from `OmniEquipment[MSPConfigT, TelemetryT]`: - Store child equipment in `self.child_equipment: dict[int, OmniEquipment]` ### State Management -- Use `@dirties_state(mspconfig=False, telemetry=True)` decorator on control methods -- Marks state as needing refresh after commands -- Default: marks telemetry dirty, not mspconfig +- Use `@control_method` decorator on all equipment control methods +- Decorator automatically checks `is_ready` before execution +- Raises `OmniEquipmentNotReadyError` with descriptive message if not ready +- Marks telemetry as dirty after successful execution - Users call `await omni.refresh()` to update state Example: ```python -@dirties_state(telemetry=True) +@control_method async def turn_on(self) -> None: - """Turn on equipment and mark telemetry as needing refresh.""" + """Turn on equipment (readiness check and state marking handled by decorator).""" + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn on: bow_id or system_id is None") await self._api.async_set_equipment(...) ``` @@ -338,12 +341,17 @@ API methods follow consistent patterns: ### Equipment Control Methods Equipment control methods: -1. Check `is_ready` property before executing -2. Raise `OmniEquipmentNotReadyError` if not ready -3. Use `@dirties_state` decorator +1. Use `@control_method` decorator (handles readiness check and state dirtying) +2. Check for required attributes (bow_id, system_id) and raise `OmniEquipmentNotInitializedError` if None +3. Validate input parameters (temperature, speed, etc.) 4. Call appropriate API method 5. Return `None` (state updated via refresh) +The `@control_method` decorator automatically: +- Checks `self.is_ready` before execution +- Raises `OmniEquipmentNotReadyError` if not ready (auto-generated message from method name) +- Marks telemetry as dirty after successful execution + ## Version Control & Semantic Versioning - Follow Semantic Versioning (currently v0.19.0) @@ -393,11 +401,16 @@ class NewEquipment(OmniEquipment[MSPNewEquipment, TelemetryNewEquipment]): """Equipment-specific property.""" return self.mspconfig.some_field - @dirties_state(telemetry=True) + @control_method async def control_method(self) -> None: - """Control method with state dirtying.""" - if not self.is_ready: - raise OmniEquipmentNotReadyError(...) + """Control method (readiness and state handled by decorator). + + Raises: + OmniEquipmentNotInitializedError: If required IDs are None. + OmniEquipmentNotReadyError: If equipment is not ready (handled by decorator). + """ + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot control: bow_id or system_id is None") await self._api.async_some_command(...) ``` diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index 3917145..6f4f053 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -1,13 +1,14 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.chlorinator import Chlorinator from pyomnilogic_local.collections import EquipmentDict -from pyomnilogic_local.colorlogiclight import _LOGGER, ColorLogicLight +from pyomnilogic_local.colorlogiclight import ColorLogicLight from pyomnilogic_local.csad import CSAD -from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.decorators import control_method from pyomnilogic_local.filter import Filter from pyomnilogic_local.heater import Heater from pyomnilogic_local.models.mspconfig import MSPBoW @@ -21,6 +22,8 @@ if TYPE_CHECKING: from pyomnilogic_local.omnilogic import OmniLogic +_LOGGER = logging.getLogger(__name__) + class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): """Represents a Body of Water (BoW) - pool or spa - in the OmniLogic system. @@ -197,7 +200,7 @@ def flow(self) -> bool: return self.telemetry.flow > 0 # Control methods - @dirties_state() + @control_method async def set_spillover(self, speed: int) -> None: """Set the spillover speed for this body of water. @@ -224,7 +227,7 @@ async def set_spillover(self, speed: int) -> None: speed=speed, ) - @dirties_state() + @control_method async def turn_on_spillover(self) -> None: """Turn on spillover at maximum speed (100%). @@ -236,7 +239,7 @@ async def turn_on_spillover(self) -> None: """ await self.set_spillover(100) - @dirties_state() + @control_method async def turn_off_spillover(self) -> None: """Turn off spillover. diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py index 8caff57..1135c74 100644 --- a/pyomnilogic_local/chlorinator.py +++ b/pyomnilogic_local/chlorinator.py @@ -5,7 +5,7 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.chlorinator_equip import ChlorinatorEquipment from pyomnilogic_local.collections import EquipmentDict -from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPChlorinator from pyomnilogic_local.models.telemetry import Telemetry, TelemetryChlorinator from pyomnilogic_local.omnitypes import ( @@ -359,7 +359,7 @@ def is_ready(self) -> bool: return self.is_authenticated and not self.has_error # Control methods - @dirties_state() + @control_method async def turn_on(self) -> None: """Turn the chlorinator on (enable it). @@ -370,7 +370,7 @@ async def turn_on(self) -> None: raise OmniEquipmentNotInitializedError("Cannot turn on chlorinator: bow_id is None") await self._api.async_set_chlorinator_enable(self.bow_id, True) - @dirties_state() + @control_method async def turn_off(self) -> None: """Turn the chlorinator off (disable it). @@ -381,7 +381,7 @@ async def turn_off(self) -> None: raise OmniEquipmentNotInitializedError("Cannot turn off chlorinator: bow_id is None") await self._api.async_set_chlorinator_enable(self.bow_id, False) - @dirties_state() + @control_method async def set_timed_percent(self, percent: int) -> None: """Set the timed percent for chlorine generation. diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index e56e059..c9f310b 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -5,7 +5,7 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.collections import LightEffectsCollection -from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPColorLogicLight from pyomnilogic_local.models.telemetry import Telemetry, TelemetryColorLogicLight from pyomnilogic_local.omnitypes import ( @@ -17,7 +17,6 @@ ) from pyomnilogic_local.util import ( OmniEquipmentNotInitializedError, - OmniEquipmentNotReadyError, ) if TYPE_CHECKING: @@ -226,43 +225,31 @@ def is_ready(self) -> bool: ColorLogicPowerState.COOLDOWN, ] - @dirties_state() + @control_method async def turn_on(self) -> None: """ Turns the light on. Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. - OmniEquipmentNotReadyError: If the light is not ready to accept commands - (in FIFTEEN_SECONDS_WHITE, CHANGING_SHOW, POWERING_OFF, or COOLDOWN state). """ if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot turn on light: bow_id or system_id is None") - if not self.is_ready: - raise OmniEquipmentNotReadyError( - f"Cannot turn on light: light is in {self.state.pretty()} state. Wait for the light to be ready before issuing commands." - ) await self._api.async_set_equipment(self.bow_id, self.system_id, True) - @dirties_state() + @control_method async def turn_off(self) -> None: """ Turns the light off. Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. - OmniEquipmentNotReadyError: If the light is not ready to accept commands - (in FIFTEEN_SECONDS_WHITE, CHANGING_SHOW, POWERING_OFF, or COOLDOWN state). """ if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot turn off light: bow_id or system_id is None") - if not self.is_ready: - raise OmniEquipmentNotReadyError( - f"Cannot turn off light: light is in {self.state.pretty()} state. Wait for the light to be ready before issuing commands." - ) await self._api.async_set_equipment(self.bow_id, self.system_id, False) - @dirties_state() + @control_method async def set_show( self, show: LightShows | None = None, speed: ColorLogicSpeed | None = None, brightness: ColorLogicBrightness | None = None ) -> None: @@ -276,8 +263,6 @@ async def set_show( Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. - OmniEquipmentNotReadyError: If the light is not ready to accept commands - (in FIFTEEN_SECONDS_WHITE, CHANGING_SHOW, POWERING_OFF, or COOLDOWN state). Note: Non color-logic lights do not support speed or brightness control. @@ -302,11 +287,6 @@ async def set_show( if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot set light show: bow_id or system_id is None") - if not self.is_ready: - raise OmniEquipmentNotReadyError( - f"Cannot set light show: light is in {self.state.pretty()} state. Wait for the light to be ready before issuing commands." - ) - await self._api.async_set_light_show( self.bow_id, self.system_id, diff --git a/pyomnilogic_local/decorators.py b/pyomnilogic_local/decorators.py index a0ace9b..b1b369f 100644 --- a/pyomnilogic_local/decorators.py +++ b/pyomnilogic_local/decorators.py @@ -5,47 +5,66 @@ import functools import logging from collections.abc import Callable -from typing import Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from pyomnilogic_local.util import OmniEquipmentNotReadyError + +if TYPE_CHECKING: + pass _LOGGER = logging.getLogger(__name__) F = TypeVar("F", bound=Callable[..., Any]) -def dirties_state(mspconfig: bool = False, telemetry: bool = True) -> Callable[[F], F]: - """Mark state as dirty after equipment control methods. +def control_method(func: F) -> F: + """Decorator for equipment control methods that checks readiness and dirties state. - This decorator marks the OmniLogic state (telemetry and/or mspconfig) as dirty - after a control method executes, indicating that the cached state is likely - out of sync with reality. Users can then call refresh() to update the state. + This decorator ensures equipment is ready before executing control methods and + automatically marks telemetry as dirty after execution. It replaces the common + pattern of checking is_ready and using @dirties_state() separately. - Args: - mspconfig: Whether to mark mspconfig as dirty (default: False) - telemetry: Whether to mark telemetry as dirty (default: True) + The decorator: + 1. Checks if equipment is ready (via is_ready property) + 2. Raises OmniEquipmentNotReadyError with descriptive message if not ready + 3. Executes the control method + 4. Marks telemetry as dirty + + Raises: + OmniEquipmentNotReadyError: If equipment is not ready to accept commands Example: - @dirties_state(telemetry=True) + @control_method async def turn_on(self) -> None: await self._api.async_set_equipment(...) + + # Replaces this pattern: + # @dirties_state() + # async def turn_on(self) -> None: + # if not self.is_ready: + # raise OmniEquipmentNotReadyError("Cannot turn on: equipment is not ready") + # await self._api.async_set_equipment(...) """ + # Import here to avoid circular dependency - def decorator(func: F) -> F: - @functools.wraps(func) - async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: - # Execute the original function - result = await func(self, *args, **kwargs) + @functools.wraps(func) + async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + # Check if equipment is ready + if not self.is_ready: + # Generate descriptive error message from function name + action = func.__name__.replace("_", " ") + msg = f"Cannot {action}: equipment is not ready to accept commands" + raise OmniEquipmentNotReadyError(msg) - # Mark state as dirty - if hasattr(self, "_omni"): - if telemetry: - self._omni._telemetry_dirty = True # pylint: disable=protected-access - if mspconfig: - self._omni._mspconfig_dirty = True # pylint: disable=protected-access - else: - _LOGGER.warning("%s does not have _omni reference, cannot mark state as dirty", self.__class__.__name__) + # Execute the original function + result = await func(self, *args, **kwargs) - return result + # Mark telemetry as dirty + if hasattr(self, "_omni"): + self._omni._telemetry_dirty = True # pylint: disable=protected-access + else: + _LOGGER.warning("%s does not have _omni reference, cannot mark state as dirty", self.__class__.__name__) - return cast(F, wrapper) + return result - return decorator + return cast(F, wrapper) diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py index 6992807..ac718e9 100644 --- a/pyomnilogic_local/filter.py +++ b/pyomnilogic_local/filter.py @@ -1,7 +1,7 @@ from __future__ import annotations from pyomnilogic_local._base import OmniEquipment -from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPFilter from pyomnilogic_local.models.telemetry import TelemetryFilter from pyomnilogic_local.omnitypes import FilterSpeedPresets, FilterState @@ -197,11 +197,14 @@ def is_ready(self) -> bool: return self.state in (FilterState.OFF, FilterState.ON) # Control methods - @dirties_state() + @control_method async def turn_on(self) -> None: """Turn the filter on. This will turn on the filter at its last used speed setting. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. """ if self.bow_id is None or self.system_id is None: msg = "Filter bow_id and system_id must be set" @@ -213,9 +216,13 @@ async def turn_on(self) -> None: is_on=self.last_speed, ) - @dirties_state() + @control_method async def turn_off(self) -> None: - """Turn the filter off.""" + """Turn the filter off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ if self.bow_id is None or self.system_id is None: msg = "Filter bow_id and system_id must be set" raise OmniEquipmentNotInitializedError(msg) @@ -226,12 +233,16 @@ async def turn_off(self) -> None: is_on=False, ) - @dirties_state() + @control_method async def run_preset_speed(self, speed: FilterSpeedPresets) -> None: """Run the filter at a preset speed. Args: speed: The preset speed to use (LOW, MEDIUM, or HIGH) + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If an invalid speed preset is provided. """ if self.bow_id is None or self.system_id is None: msg = "Filter bow_id and system_id must be set" @@ -255,7 +266,7 @@ async def run_preset_speed(self, speed: FilterSpeedPresets) -> None: is_on=speed_value, ) - @dirties_state() + @control_method async def set_speed(self, speed: int) -> None: """Set the filter to a specific speed. diff --git a/pyomnilogic_local/groups.py b/pyomnilogic_local/groups.py index 9c09c51..0e681f3 100644 --- a/pyomnilogic_local/groups.py +++ b/pyomnilogic_local/groups.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment -from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPGroup from pyomnilogic_local.models.telemetry import Telemetry, TelemetryGroup from pyomnilogic_local.omnitypes import GroupState @@ -88,7 +88,7 @@ def is_on(self) -> bool: """Returns whether the group is currently active.""" return self.state == GroupState.ON - @dirties_state() + @control_method async def turn_on(self) -> None: """ Activates the group, turning on all equipment assigned to it. @@ -100,7 +100,7 @@ async def turn_on(self) -> None: raise OmniEquipmentNotInitializedError("Cannot turn on group: system_id is None") await self._api.async_set_group_enable(self.system_id, True) - @dirties_state() + @control_method async def turn_off(self) -> None: """ Deactivates the group, turning off all equipment assigned to it. diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py index 211eb13..2562344 100644 --- a/pyomnilogic_local/heater.py +++ b/pyomnilogic_local/heater.py @@ -4,7 +4,7 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.collections import EquipmentDict -from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.decorators import control_method from pyomnilogic_local.heater_equip import HeaterEquipment from pyomnilogic_local.models.mspconfig import MSPVirtualHeater from pyomnilogic_local.models.telemetry import Telemetry, TelemetryVirtualHeater @@ -185,31 +185,33 @@ def is_on(self) -> bool: """Returns whether the heater is currently enabled (from telemetry).""" return self.telemetry.enabled - @dirties_state() + @control_method async def turn_on(self) -> None: """ Turns the heater on (enables it). Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the equipment is not ready to accept commands. """ if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot turn on heater: bow_id or system_id is None") await self._api.async_set_heater_enable(self.bow_id, self.system_id, True) - @dirties_state() + @control_method async def turn_off(self) -> None: """ Turns the heater off (disables it). Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the equipment is not ready to accept commands. """ if self.bow_id is None or self.system_id is None: raise OmniEquipmentNotInitializedError("Cannot turn off heater: bow_id or system_id is None") await self._api.async_set_heater_enable(self.bow_id, self.system_id, False) - @dirties_state() + @control_method async def set_temperature(self, temperature: int) -> None: """ Sets the target temperature for the heater. @@ -220,6 +222,7 @@ async def set_temperature(self, temperature: int) -> None: Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the equipment is not ready to accept commands. ValueError: If temperature is outside the valid range. Note: @@ -237,7 +240,7 @@ async def set_temperature(self, temperature: int) -> None: # Always use Fahrenheit as that's what the OmniLogic system uses internally await self._api.async_set_heater(self.bow_id, self.system_id, temperature) - @dirties_state() + @control_method async def set_solar_temperature(self, temperature: int) -> None: """ Sets the solar heater set point. @@ -248,6 +251,7 @@ async def set_solar_temperature(self, temperature: int) -> None: Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the equipment is not ready to accept commands. ValueError: If temperature is outside the valid range. Note: diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index 44458e8..f83733c 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -63,7 +63,7 @@ class TelemetryBackyard(BaseModel): air_temp: int | None = Field(alias="@airTemp") state: BackyardState = Field(alias="@state") # The below two fields are only available for telemetry with a status_version >= 11 - config_checksum: int | None = Field(alias="@ConfigChksum", default=None) + config_checksum: int = Field(alias="@ConfigChksum", default=0) msp_version: str | None = Field(alias="@mspVersion", default=None) diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index 7b927da..0d9cf39 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -39,9 +39,13 @@ class OmniLogic: _mspconfig_last_updated: float = 0.0 _telemetry_last_updated: float = 0.0 - _mspconfig_dirty: bool = True + _mspconfig_checksum: int = 0 _telemetry_dirty: bool = True _refresh_lock: asyncio.Lock + # This is the minimum supported MSP version for full functionality + # we just string match the value from the start of the string + _min_mspversion: str = "R05" + _warned_mspversion: bool = False def __init__(self, host: str, port: int = 10444) -> None: self.host = host @@ -74,8 +78,6 @@ def __repr__(self) -> str: async def refresh( self, *, - mspconfig: bool = True, - telemetry: bool = True, if_dirty: bool = True, if_older_than: float = 10.0, force: bool = False, @@ -92,37 +94,45 @@ async def refresh( async with self._refresh_lock: current_time = time.time() - # Determine if mspconfig needs updating - update_mspconfig = False - if mspconfig: - if force: - update_mspconfig = True - elif if_dirty and self._mspconfig_dirty: - update_mspconfig = True - elif (current_time - self._mspconfig_last_updated) > if_older_than: - update_mspconfig = True - # Determine if telemetry needs updating update_telemetry = False - if telemetry: - if force: - update_telemetry = True - elif if_dirty and self._telemetry_dirty: - update_telemetry = True - elif (current_time - self._telemetry_last_updated) > if_older_than: - update_telemetry = True - - # Perform the updates - if update_mspconfig: - self.mspconfig = await self._api.async_get_mspconfig() - self._mspconfig_last_updated = time.time() - self._mspconfig_dirty = False - + if force: + update_telemetry = True + elif if_dirty and self._telemetry_dirty: + update_telemetry = True + elif (current_time - self._telemetry_last_updated) > if_older_than: + update_telemetry = True + + # Update telemetry if needed if update_telemetry: self.telemetry = await self._api.async_get_telemetry() self._telemetry_last_updated = time.time() self._telemetry_dirty = False + # Determine if MSPConfig needs updating + update_mspconfig = False + if force: + update_mspconfig = True + if self.telemetry.backyard.config_checksum != self._mspconfig_checksum: + update_mspconfig = True + + if self.telemetry.backyard.msp_version is not None: + if not self._warned_mspversion and not self.telemetry.backyard.msp_version.startswith(self._min_mspversion): + _LOGGER.warning( + "Detected OmniLogic MSP version %s, which is below the minimum supported version %s. " + "Some features may not work correctly. Please consider updating your OmniLogic controller firmware.", + self.telemetry.backyard.msp_version, + self._min_mspversion, + ) + self._warned_mspversion = True + + # Update MSPConfig if needed + if update_mspconfig: + _LOGGER.warning("MSPConfig data is outdated or changed; refreshing configuration data") + self.mspconfig = await self._api.async_get_mspconfig() + self._mspconfig_last_updated = time.time() + self._mspconfig_checksum = self.telemetry.backyard.config_checksum + if update_mspconfig or update_telemetry: self._update_equipment() diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index 0161152..6c75309 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -1,7 +1,7 @@ from __future__ import annotations from pyomnilogic_local._base import OmniEquipment -from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPPump from pyomnilogic_local.models.telemetry import TelemetryPump from pyomnilogic_local.omnitypes import PumpSpeedPresets, PumpState @@ -179,11 +179,14 @@ def is_ready(self) -> bool: return self.state in (PumpState.OFF, PumpState.ON) # Control methods - @dirties_state() + @control_method async def turn_on(self) -> None: """Turn the pump on. This will turn on the pump at its last used speed setting. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. """ if self.bow_id is None or self.system_id is None: msg = "Pump bow_id and system_id must be set" @@ -195,9 +198,13 @@ async def turn_on(self) -> None: is_on=True, ) - @dirties_state() + @control_method async def turn_off(self) -> None: - """Turn the pump off.""" + """Turn the pump off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ if self.bow_id is None or self.system_id is None: msg = "Pump bow_id and system_id must be set" raise OmniEquipmentNotInitializedError(msg) @@ -208,7 +215,7 @@ async def turn_off(self) -> None: is_on=False, ) - @dirties_state() + @control_method async def run_preset_speed(self, speed: PumpSpeedPresets) -> None: """Run the pump at a preset speed. @@ -241,7 +248,7 @@ async def run_preset_speed(self, speed: PumpSpeedPresets) -> None: is_on=speed_value, ) - @dirties_state() + @control_method async def set_speed(self, speed: int) -> None: """Set the pump to a specific speed. diff --git a/pyomnilogic_local/relay.py b/pyomnilogic_local/relay.py index 63be424..e2f64a7 100644 --- a/pyomnilogic_local/relay.py +++ b/pyomnilogic_local/relay.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from pyomnilogic_local._base import OmniEquipment -from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPRelay from pyomnilogic_local.models.telemetry import Telemetry, TelemetryRelay from pyomnilogic_local.omnitypes import RelayFunction, RelayState, RelayType, RelayWhyOn @@ -101,7 +101,7 @@ def is_on(self) -> bool: """Returns whether the relay is currently on.""" return self.state == RelayState.ON - @dirties_state() + @control_method async def turn_on(self) -> None: """ Turns the relay on. @@ -113,7 +113,7 @@ async def turn_on(self) -> None: raise OmniEquipmentNotInitializedError("Cannot turn on relay: bow_id or system_id is None") await self._api.async_set_equipment(self.bow_id, self.system_id, True) - @dirties_state() + @control_method async def turn_off(self) -> None: """ Turns the relay off. diff --git a/pyomnilogic_local/schedule.py b/pyomnilogic_local/schedule.py index 8c96964..8e8473e 100644 --- a/pyomnilogic_local/schedule.py +++ b/pyomnilogic_local/schedule.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any from pyomnilogic_local._base import OmniEquipment -from pyomnilogic_local.decorators import dirties_state +from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPSchedule from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.util import OmniEquipmentNotInitializedError @@ -145,7 +145,7 @@ def controlled_equipment(self) -> OmniEquipment[Any, Any] | None: """ return self._omni.get_equipment_by_id(self.equipment_id) - @dirties_state() + @control_method async def turn_on(self) -> None: """ Enable the schedule. @@ -172,7 +172,7 @@ async def turn_on(self) -> None: recurring=self.recurring, ) - @dirties_state() + @control_method async def turn_off(self) -> None: """ Disable the schedule. diff --git a/pyproject.toml b/pyproject.toml index f7cf44b..2a1f912 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,27 +97,27 @@ max-line-length=140 # consider-using-assignment-expr - decision to use := better left to devs disable = [ "format", - "abstract-method", - "cyclic-import", + # "abstract-method", + # "cyclic-import", "duplicate-code", - "inconsistent-return-statements", + # "inconsistent-return-statements", "locally-disabled", - "not-context-manager", - "too-few-public-methods", - "too-many-ancestors", + # "not-context-manager", + # "too-few-public-methods", + # "too-many-ancestors", "too-many-arguments", "too-many-branches", "too-many-instance-attributes", - "too-many-lines", + # "too-many-lines", "too-many-locals", "too-many-public-methods", "too-many-return-statements", - "too-many-statements", - "too-many-boolean-expressions", + # "too-many-statements", + # "too-many-boolean-expressions", "unused-argument", "wrong-import-order", "wrong-import-position", - "consider-using-f-string", + # "consider-using-f-string", # The below are only here for now, we should fully document once the codebase stops fluctuating so much "missing-class-docstring", "missing-function-docstring", From a3028feba2ecce8862bcb2485522b671039aa138 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:17:17 -0600 Subject: [PATCH 47/61] test: add decorators test file --- .github/copilot-instructions.md | 13 ++- tests/test_decorators.py | 188 ++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/test_decorators.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 98ab829..8de9e16 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -186,7 +186,18 @@ def _validate_temperature(temperature: int, param_name: str = "temperature") -> - Provide clear error messages with parameter names and values ### Async Patterns -- **Prefix async methods**: `async_get_telemetry`, `async_set_heater`, etc. + +#### Async Method Naming +- **API layer**: All async methods MUST use `async_` prefix + - Example: `async_send_message`, `async_get_telemetry`, `async_set_equipment`, `async_set_heater` + - Rationale: Clear indication that these are async protocol/network operations +- **Equipment layer**: User-facing control methods do NOT use `async_` prefix + - Example: `turn_on`, `turn_off`, `set_speed`, `set_temperature`, `set_show` + - Rationale: Cleaner, more intuitive API for end users (they already use `await`) +- **Internal utilities**: Use `async_` prefix for non-user-facing async functions + - Example: `async_get_filter_diagnostics` in CLI utils + +#### Async Best Practices - Use `asyncio.get_running_loop()` for low-level operations - Properly manage transport lifecycle (create and close in try/finally) - Equipment control methods are async and use `@control_method` decorator diff --git a/tests/test_decorators.py b/tests/test_decorators.py new file mode 100644 index 0000000..2c3149a --- /dev/null +++ b/tests/test_decorators.py @@ -0,0 +1,188 @@ +# pylint: skip-file +# type: ignore + +""" +Tests for equipment control method decorators. + +Focuses on: +- @control_method decorator behavior +- Readiness checking +- State dirtying +- Error message generation +""" + +from unittest.mock import MagicMock + +import pytest + +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.util import OmniEquipmentNotReadyError + +# ============================================================================ +# Test Fixtures +# ============================================================================ + + +class MockEquipment: + """Mock equipment class for testing decorators.""" + + def __init__(self, is_ready: bool = True): + """Initialize mock equipment. + + Args: + is_ready: Whether equipment should report as ready + """ + self.is_ready = is_ready + self._omni = MagicMock() + self._omni._telemetry_dirty = False + self.method_called = False + self.method_args = None + self.method_kwargs = None + + @control_method + async def turn_on(self) -> None: + """Mock turn_on method.""" + self.method_called = True + + @control_method + async def turn_off(self) -> None: + """Mock turn_off method.""" + self.method_called = True + + @control_method + async def set_temperature(self, temperature: int) -> None: + """Mock set_temperature method with args.""" + self.method_called = True + self.method_args = (temperature,) + + @control_method + async def set_complex_operation(self, param1: int, param2: str, flag: bool = False) -> str: + """Mock method with args, kwargs, and return value.""" + self.method_called = True + self.method_args = (param1, param2) + self.method_kwargs = {"flag": flag} + return f"result: {param1}, {param2}, {flag}" + + +# ============================================================================ +# @control_method Decorator Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_control_method_when_ready_executes_function(): + """Test that control_method executes the wrapped function when equipment is ready.""" + equipment = MockEquipment(is_ready=True) + + await equipment.turn_on() + + assert equipment.method_called is True + + +@pytest.mark.asyncio +async def test_control_method_when_not_ready_raises_error(): + """Test that control_method raises OmniEquipmentNotReadyError when equipment is not ready.""" + equipment = MockEquipment(is_ready=False) + + with pytest.raises(OmniEquipmentNotReadyError) as exc_info: + await equipment.turn_on() + + assert "Cannot turn on: equipment is not ready to accept commands" in str(exc_info.value) + assert equipment.method_called is False + + +@pytest.mark.asyncio +async def test_control_method_marks_telemetry_dirty(): + """Test that control_method marks telemetry as dirty after successful execution.""" + equipment = MockEquipment(is_ready=True) + + assert equipment._omni._telemetry_dirty is False + + await equipment.turn_on() + + assert equipment._omni._telemetry_dirty is True + + +@pytest.mark.asyncio +async def test_control_method_does_not_mark_dirty_if_not_ready(): + """Test that control_method does not mark state dirty if readiness check fails.""" + equipment = MockEquipment(is_ready=False) + + with pytest.raises(OmniEquipmentNotReadyError): + await equipment.turn_on() + + assert equipment._omni._telemetry_dirty is False + + +@pytest.mark.asyncio +async def test_control_method_passes_arguments(): + """Test that control_method properly passes arguments to wrapped function.""" + equipment = MockEquipment(is_ready=True) + + await equipment.set_temperature(75) + + assert equipment.method_called is True + assert equipment.method_args == (75,) + + +@pytest.mark.asyncio +async def test_control_method_passes_kwargs(): + """Test that control_method properly passes keyword arguments to wrapped function.""" + equipment = MockEquipment(is_ready=True) + + result = await equipment.set_complex_operation(42, "test", flag=True) + + assert equipment.method_called is True + assert equipment.method_args == (42, "test") + assert equipment.method_kwargs == {"flag": True} + assert result == "result: 42, test, True" + + +@pytest.mark.asyncio +async def test_control_method_error_message_for_different_methods(): + """Test that control_method generates appropriate error messages for different method names.""" + equipment = MockEquipment(is_ready=False) + + # Test turn_on + with pytest.raises(OmniEquipmentNotReadyError) as exc_info: + await equipment.turn_on() + assert "Cannot turn on:" in str(exc_info.value) + + # Test turn_off + with pytest.raises(OmniEquipmentNotReadyError) as exc_info: + await equipment.turn_off() + assert "Cannot turn off:" in str(exc_info.value) + + # Test set_temperature + with pytest.raises(OmniEquipmentNotReadyError) as exc_info: + await equipment.set_temperature(75) + assert "Cannot set temperature:" in str(exc_info.value) + + # Test complex operation + with pytest.raises(OmniEquipmentNotReadyError) as exc_info: + await equipment.set_complex_operation(1, "test") + assert "Cannot set complex operation:" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_control_method_preserves_function_metadata(): + """Test that control_method preserves the wrapped function's metadata.""" + equipment = MockEquipment(is_ready=True) + + # Check that functools.wraps preserved the original function name and docstring + assert equipment.turn_on.__name__ == "turn_on" + assert equipment.turn_on.__doc__ == "Mock turn_on method." + assert equipment.set_temperature.__name__ == "set_temperature" + assert "args" in equipment.set_temperature.__doc__ + + +@pytest.mark.asyncio +async def test_control_method_without_omni_reference(): + """Test that control_method logs warning when equipment lacks _omni reference.""" + equipment = MockEquipment(is_ready=True) + del equipment._omni + + # Should still execute the function without error, just log a warning + await equipment.turn_on() + + assert equipment.method_called is True From fdfbfdf544449f1c8d26acdd8c44dde22dd81450 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:20:42 -0600 Subject: [PATCH 48/61] fix: remove testing logger line --- pyomnilogic_local/omnilogic.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index 0d9cf39..05e2869 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -128,7 +128,6 @@ async def refresh( # Update MSPConfig if needed if update_mspconfig: - _LOGGER.warning("MSPConfig data is outdated or changed; refreshing configuration data") self.mspconfig = await self._api.async_get_mspconfig() self._mspconfig_last_updated = time.time() self._mspconfig_checksum = self.telemetry.backyard.config_checksum From 0165113aa3b4b3dc432d766ea19fe0a78abd87e1 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:46:13 -0600 Subject: [PATCH 49/61] doc: improve docstrings for telemetry module --- pyomnilogic_local/models/telemetry.py | 172 +++++++++++++++++++++++++- pyomnilogic_local/omnitypes.py | 2 +- 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index f83733c..af3c631 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -20,6 +20,7 @@ ColorLogicShowUCLV2, ColorLogicSpeed, CSADMode, + CSADStatus, FilterState, FilterValvePosition, FilterWhyOn, @@ -55,6 +56,18 @@ class TelemetryBackyard(BaseModel): + """Real-time telemetry for the backyard/controller system. + + This is the top-level telemetry object containing system-wide state information. + Always present in telemetry responses. + + Fields: + air_temp: Air temperature in Fahrenheit, None if sensor unavailable + state: Current operational state (ON, OFF, SERVICE_MODE, etc.) + config_checksum: Configuration version identifier for detecting changes + msp_version: Controller firmware version (available in status_version >= 11) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.BACKYARD @@ -68,6 +81,15 @@ class TelemetryBackyard(BaseModel): class TelemetryBoW(BaseModel): + """Real-time telemetry for a body of water (pool or spa). + + Contains current water conditions and flow status. + + Fields: + water_temp: Water temperature in Fahrenheit, -1 if sensor unavailable + flow: Flow sensor value, 255 or 1 typically indicate flow detected, 0 for no flow + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.BOW @@ -77,6 +99,22 @@ class TelemetryBoW(BaseModel): class TelemetryChlorinator(BaseModel): + """Real-time telemetry for salt chlorinator systems. + + Includes salt levels, operational status, alerts, and errors. Use computed + properties (status, alerts, errors) for decoded bitmask values. + + Fields: + instant_salt_level: Current salt reading in PPM + avg_salt_level: Average salt level in PPM over time + status_raw: Bitmask of operational status flags (use .status property for decoded properties) + chlr_alert_raw: Bitmask of alert conditions (use .alerts property for decoded properties) + chlr_error_raw: Bitmask of error conditions (use .errors property for decoded properties) + timed_percent: Chlorination output percentage in timed mode (0-100), None if not applicable + operating_mode: DISABLED, TIMED, ORP_AUTO, or ORP_TIMED_RW + enable: Whether chlorinator is enabled for operation + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CHLORINATOR @@ -180,17 +218,43 @@ def active(self) -> bool: class TelemetryCSAD(BaseModel): + """Real-time telemetry for Chemistry Sense and Dispense systems. + + Provides current water chemistry readings and dispensing status. + + Fields: + ph: Current pH level reading (typically 0.0-14.0) + orp: Oxidation-Reduction Potential in millivolts + mode: Current operation mode (OFF, AUTO, FORCE_ON, MONITORING, DISPENSING_OFF) + status: Dispensing status (NOT_DISPENSING, DISPENSING) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CSAD system_id: int = Field(alias="@systemId") - status_raw: int = Field(alias="@status") + status: CSADStatus = Field(alias="@status") ph: float = Field(alias="@ph") orp: int = Field(alias="@orp") mode: CSADMode = Field(alias="@mode") class TelemetryColorLogicLight(BaseModel): + """Real-time telemetry for ColorLogic LED lighting systems. + + Tracks power state, active show, speed, and brightness settings. Light cannot + accept commands during transitional states (CHANGING_SHOW, POWERING_OFF, COOLDOWN). + + Not all fields are applicable to all light models. + + Fields: + state: Power/operational state (OFF, ACTIVE, transitional states) + show: Currently active light show (type depends on light model) + speed: Animation speed (ONE_SIXTEENTH to SIXTEEN_TIMES) + brightness: Light brightness level (TWENTY_PERCENT to ONE_HUNDRED_PERCENT) + special_effect: Special effect identifier (usage varies by model) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CL_LIGHT @@ -227,6 +291,21 @@ def show_name( class TelemetryFilter(BaseModel): + """Real-time telemetry for filter pump systems. + + Includes operational state, speed settings, and valve position. Filter cannot + accept commands during transitional states (PRIMING, COOLDOWN, etc.). + + Fields: + state: Current operational state (OFF, ON, transitional states) + speed: Current speed setting (percentage 0-100) + valve_position: Current valve position for multi-port systems + why_on: Reason filter is running (MANUAL_ON, TIMED_EVENT, FREEZE_PROTECT, etc.) + reported_speed: Actual reported speed from variable speed pump (percentage 0-100) + power: Current power consumption (watts) + last_speed: Previous speed setting before state change + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.FILTER @@ -241,6 +320,14 @@ class TelemetryFilter(BaseModel): class TelemetryGroup(BaseModel): + """Real-time telemetry for equipment groups. + + Groups allow controlling multiple pieces of equipment together as a single unit. + + Fields: + state: Current group state (OFF or ON) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.GROUP @@ -249,6 +336,19 @@ class TelemetryGroup(BaseModel): class TelemetryHeater(BaseModel): + """Real-time telemetry for physical heater equipment. + + Represents actual heater hardware (gas, heat pump, solar, etc.) controlled + by a VirtualHeater. See TelemetryVirtualHeater for set points and modes. + + Fields: + state: Current heater state (OFF, ON, PAUSE) + temp: Current water temperature reading in Fahrenheit + enabled: Whether heater is enabled for operation + priority: Heater priority for sequencing + maintain_for: Hours to maintain temperature after reaching set point + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.HEATER @@ -261,6 +361,18 @@ class TelemetryHeater(BaseModel): class TelemetryPump(BaseModel): + """Real-time telemetry for auxiliary pump equipment. + + Auxiliary pumps are separate from filter pumps and used for water features, + cleaners, etc. Pump cannot accept commands during transitional states. + + Fields: + state: Current pump state (OFF, ON, FREEZE_PROTECT) + speed: Current speed setting (percentage 0-100 or RPM depending on type) + last_speed: Previous speed setting before state change + why_on: Reason pump is running (usage similar to FilterWhyOn) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.PUMP @@ -272,6 +384,16 @@ class TelemetryPump(BaseModel): class TelemetryRelay(BaseModel): + """Real-time telemetry for relay-controlled equipment. + + Relays provide simple on/off control for lights, water features, and other + accessories not requiring variable speed control. + + Fields: + state: Current relay state (OFF or ON) + why_on: Reason relay is on (MANUAL_ON, SCHEDULE_ON, GROUP_ON, FREEZE_PROTECT, etc.) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.RELAY @@ -281,6 +403,16 @@ class TelemetryRelay(BaseModel): class TelemetryValveActuator(BaseModel): + """Real-time telemetry for valve actuator equipment. + + Valve actuators control motorized valves for directing water flow. Functionally + similar to relays with on/off states. + + Fields: + state: Current valve state (OFF or ON) + why_on: Reason valve is active (uses RelayWhyOn enum values) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.VALVE_ACTUATOR @@ -291,6 +423,20 @@ class TelemetryValveActuator(BaseModel): class TelemetryVirtualHeater(BaseModel): + """Real-time telemetry for virtual heater controller. + + Virtual heater acts as the control logic for one or more physical heaters, + managing set points, modes, and sequencing. Each body of water has one virtual heater. + + Fields: + current_set_point: Active temperature target in Fahrenheit + enabled: Whether heating/cooling is enabled + solar_set_point: Solar heater set point in Fahrenheit + mode: Operating mode (HEAT, COOL, or AUTO) + silent_mode: Heat pump quiet mode setting + why_on: Reason heater is active (usage varies) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.VIRT_HEATER @@ -320,6 +466,30 @@ class TelemetryVirtualHeater(BaseModel): class Telemetry(BaseModel): + """Complete real-time telemetry snapshot from the OmniLogic controller. + + Contains the current state of all equipment in the system. Telemetry is requested + via async_get_telemetry() and should be refreshed periodically to get current values. + + All equipment collections except backyard and bow are optional and will be None + if no equipment of that type exists in the system. + + Fields: + version: Telemetry format version from controller + backyard: System-wide state (always present) + bow: Bodies of water telemetry (always present, one or more) + chlorinator: Salt chlorinator telemetry (optional) + colorlogic_light: LED light telemetry (optional) + csad: Chemistry controller telemetry (optional) + filter: Filter pump telemetry (optional) + group: Equipment group telemetry (optional) + heater: Physical heater telemetry (optional) + pump: Auxiliary pump telemetry (optional) + relay: Relay-controlled equipment telemetry (optional) + valve_actuator: Valve actuator telemetry (optional) + virtual_heater: Heater controller telemetry (optional) + """ + model_config = ConfigDict(from_attributes=True) version: str = Field(alias="@version") diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index 203f387..b452b70 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -150,7 +150,7 @@ class ChlorinatorOperatingMode(IntEnum, PrettyEnum): DISABLED = 0 TIMED = 1 ORP_AUTO = 2 - ORP_TIMED_RW = 3 # CSAD in ORP mode experienced condition that prevents ORP operation + ORP_TIMED_RW = 3 # Chlorinator in ORP mode experienced condition that prevents ORP operation class ChlorinatorType(StrEnum, PrettyEnum): From 24d7c5def10d58d9aef9c184bb2e2ce0b3e908aa Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 22:08:55 -0600 Subject: [PATCH 50/61] feat: start migrating to pydantic-xml, add parsing tests --- .pre-commit-config.yaml | 2 +- pyomnilogic_local/csad.py | 6 +- pyomnilogic_local/models/telemetry.py | 249 +++++++-------- pyomnilogic_local/omnitypes.py | 2 +- pyproject.toml | 2 + tests/fixtures/issue-144.json | 4 + tests/fixtures/issue-163.json | 4 + tests/fixtures/issue-60.json | 4 + tests/test_chlorinator_bitmask.py | 122 ++++---- tests/test_chlorinator_multibit.py | 160 +++++----- tests/test_filter_pump.py | 26 +- tests/test_fixtures.py | 431 ++++++++++++++++++++++++++ uv.lock | 15 + 13 files changed, 726 insertions(+), 301 deletions(-) create mode 100644 tests/fixtures/issue-144.json create mode 100644 tests/fixtures/issue-163.json create mode 100644 tests/fixtures/issue-60.json create mode 100644 tests/test_fixtures.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 69515d0..4c43947 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,5 +54,5 @@ repos: hooks: - id: mypy exclude: cli.py - additional_dependencies: [ "pydantic>=2.0.0", "pytest>=8.0.0" ] + additional_dependencies: [ "pydantic>=2.0.0", "pydantic-xml>=2.18.0", "pytest>=8.0.0" ] args: [ "--config-file=./pyproject.toml", "--follow-imports=silent", "--strict", "--ignore-missing-imports", "--disallow-subclassing-any", "--no-warn-return-any" ] diff --git a/pyomnilogic_local/csad.py b/pyomnilogic_local/csad.py index 120e5bf..24bde06 100644 --- a/pyomnilogic_local/csad.py +++ b/pyomnilogic_local/csad.py @@ -123,9 +123,9 @@ def orp_forced_enabled(self) -> bool: # Expose Telemetry attributes @property - def status_raw(self) -> int: + def status(self) -> CSADStatus: """Raw status value from telemetry.""" - return self.telemetry.status_raw + return self.telemetry.status @property def current_ph(self) -> float: @@ -188,7 +188,7 @@ def state(self) -> CSADStatus: >>> if csad.state == CSADStatus.DISPENSING: ... print("Dispensing chemicals") """ - return CSADStatus(self.status_raw) + return self.status @property def is_on(self) -> bool: diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index af3c631..34cef8a 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import Any, SupportsInt, cast, overload +from typing import cast -from pydantic import BaseModel, ConfigDict, Field, ValidationError, computed_field -from xmltodict import parse as xml_parse +from pydantic import ConfigDict, ValidationError +from pydantic_xml import BaseXmlModel, attr, element from ..omnitypes import ( BackyardState, @@ -55,7 +55,7 @@ # -class TelemetryBackyard(BaseModel): +class TelemetryBackyard(BaseXmlModel, tag="Backyard"): """Real-time telemetry for the backyard/controller system. This is the top-level telemetry object containing system-wide state information. @@ -71,16 +71,16 @@ class TelemetryBackyard(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.BACKYARD - system_id: int = Field(alias="@systemId") - status_version: int = Field(alias="@statusVersion") - air_temp: int | None = Field(alias="@airTemp") - state: BackyardState = Field(alias="@state") + system_id: int = attr(name="systemId") + status_version: int = attr(name="statusVersion") + air_temp: int | None = attr(name="airTemp") + state: BackyardState = attr() # The below two fields are only available for telemetry with a status_version >= 11 - config_checksum: int = Field(alias="@ConfigChksum", default=0) - msp_version: str | None = Field(alias="@mspVersion", default=None) + config_checksum: int = attr(name="ConfigChksum", default=0) + msp_version: str | None = attr(name="mspVersion", default=None) -class TelemetryBoW(BaseModel): +class TelemetryBoW(BaseXmlModel, tag="BodyOfWater"): """Real-time telemetry for a body of water (pool or spa). Contains current water conditions and flow status. @@ -93,12 +93,12 @@ class TelemetryBoW(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.BOW - system_id: int = Field(alias="@systemId") - water_temp: int = Field(alias="@waterTemp") - flow: int = Field(alias="@flow") + system_id: int = attr(name="systemId") + water_temp: int = attr(name="waterTemp") + flow: int = attr() -class TelemetryChlorinator(BaseModel): +class TelemetryChlorinator(BaseXmlModel, tag="Chlorinator"): """Real-time telemetry for salt chlorinator systems. Includes salt levels, operational status, alerts, and errors. Use computed @@ -118,19 +118,18 @@ class TelemetryChlorinator(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CHLORINATOR - system_id: int = Field(alias="@systemId") - status_raw: int = Field(alias="@status") - instant_salt_level: int = Field(alias="@instantSaltLevel") - avg_salt_level: int = Field(alias="@avgSaltLevel") - chlr_alert_raw: int = Field(alias="@chlrAlert") - chlr_error_raw: int = Field(alias="@chlrError") - sc_mode: int = Field(alias="@scMode") - operating_state: int = Field(alias="@operatingState") - timed_percent: int | None = Field(alias="@Timed-Percent", default=None) - operating_mode: ChlorinatorOperatingMode = Field(alias="@operatingMode") - enable: bool = Field(alias="@enable") - - @computed_field # type: ignore[prop-decorator] + system_id: int = attr(name="systemId") + status_raw: int = attr(name="status") + instant_salt_level: int = attr(name="instantSaltLevel") + avg_salt_level: int = attr(name="avgSaltLevel") + chlr_alert_raw: int = attr(name="chlrAlert") + chlr_error_raw: int = attr(name="chlrError") + sc_mode: int = attr(name="scMode") + operating_state: int = attr(name="operatingState") + timed_percent: int | None = attr(name="Timed-Percent", default=None) + operating_mode: ChlorinatorOperatingMode = attr(name="operatingMode") + enable: bool = attr() + @property def status(self) -> list[str]: """Decode status bitmask into a list of active status flag names. @@ -144,7 +143,6 @@ def status(self) -> list[str]: """ return [flag.name for flag in ChlorinatorStatus if self.status_raw & flag.value and flag.name is not None] - @computed_field # type: ignore[prop-decorator] @property def alerts(self) -> list[str]: """Decode chlrAlert bitmask into a list of active alert flag names. @@ -175,7 +173,6 @@ def alerts(self) -> list[str]: return final_flags - @computed_field # type: ignore[prop-decorator] @property def errors(self) -> list[str]: """Decode chlrError bitmask into a list of active error flag names. @@ -206,7 +203,6 @@ def errors(self) -> list[str]: return final_flags - @computed_field # type: ignore[prop-decorator] @property def active(self) -> bool: """Check if the chlorinator is actively generating chlorine. @@ -217,7 +213,7 @@ def active(self) -> bool: return ChlorinatorStatus.GENERATING.value & self.status_raw == ChlorinatorStatus.GENERATING.value -class TelemetryCSAD(BaseModel): +class TelemetryCSAD(BaseXmlModel, tag="CSAD"): """Real-time telemetry for Chemistry Sense and Dispense systems. Provides current water chemistry readings and dispensing status. @@ -232,14 +228,14 @@ class TelemetryCSAD(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CSAD - system_id: int = Field(alias="@systemId") - status: CSADStatus = Field(alias="@status") - ph: float = Field(alias="@ph") - orp: int = Field(alias="@orp") - mode: CSADMode = Field(alias="@mode") + system_id: int = attr(name="systemId") + status: CSADStatus = attr() + ph: float = attr() + orp: int = attr() + mode: CSADMode = attr() -class TelemetryColorLogicLight(BaseModel): +class TelemetryColorLogicLight(BaseXmlModel, tag="ColorLogic-Light"): """Real-time telemetry for ColorLogic LED lighting systems. Tracks power state, active show, speed, and brightness settings. Light cannot @@ -258,12 +254,12 @@ class TelemetryColorLogicLight(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CL_LIGHT - system_id: int = Field(alias="@systemId") - state: ColorLogicPowerState = Field(alias="@lightState") - show: LightShows = Field(alias="@currentShow") - speed: ColorLogicSpeed = Field(alias="@speed") - brightness: ColorLogicBrightness = Field(alias="@brightness") - special_effect: int = Field(alias="@specialEffect") + system_id: int = attr(name="systemId") + state: ColorLogicPowerState = attr(name="lightState") + show: LightShows = attr(name="currentShow") + speed: ColorLogicSpeed = attr() + brightness: ColorLogicBrightness = attr() + special_effect: int = attr(name="specialEffect") def show_name( self, model: ColorLogicLightType, v2: bool, pretty: bool = False @@ -290,7 +286,7 @@ def show_name( return self.show # Return raw int if type is unknown -class TelemetryFilter(BaseModel): +class TelemetryFilter(BaseXmlModel, tag="Filter"): """Real-time telemetry for filter pump systems. Includes operational state, speed settings, and valve position. Filter cannot @@ -309,17 +305,17 @@ class TelemetryFilter(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.FILTER - system_id: int = Field(alias="@systemId") - state: FilterState = Field(alias="@filterState") - speed: int = Field(alias="@filterSpeed") - valve_position: FilterValvePosition = Field(alias="@valvePosition") - why_on: FilterWhyOn = Field(alias="@whyFilterIsOn") - reported_speed: int = Field(alias="@reportedFilterSpeed") - power: int = Field(alias="@power") - last_speed: int = Field(alias="@lastSpeed") + system_id: int = attr(name="systemId") + state: FilterState = attr(name="filterState") + speed: int = attr(name="filterSpeed") + valve_position: FilterValvePosition = attr(name="valvePosition") + why_on: FilterWhyOn = attr(name="whyFilterIsOn") + reported_speed: int = attr(name="reportedFilterSpeed") + power: int = attr() + last_speed: int = attr(name="lastSpeed") -class TelemetryGroup(BaseModel): +class TelemetryGroup(BaseXmlModel, tag="Group"): """Real-time telemetry for equipment groups. Groups allow controlling multiple pieces of equipment together as a single unit. @@ -331,11 +327,11 @@ class TelemetryGroup(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.GROUP - system_id: int = Field(alias="@systemId") - state: GroupState = Field(alias="@groupState") + system_id: int = attr(name="systemId") + state: GroupState = attr(name="groupState") -class TelemetryHeater(BaseModel): +class TelemetryHeater(BaseXmlModel, tag="Heater"): """Real-time telemetry for physical heater equipment. Represents actual heater hardware (gas, heat pump, solar, etc.) controlled @@ -352,15 +348,15 @@ class TelemetryHeater(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.HEATER - system_id: int = Field(alias="@systemId") - state: HeaterState = Field(alias="@heaterState") - temp: int = Field(alias="@temp") - enabled: bool = Field(alias="@enable") - priority: int = Field(alias="@priority") - maintain_for: int = Field(alias="@maintainFor") + system_id: int = attr(name="systemId") + state: HeaterState = attr(name="heaterState") + temp: int = attr() + enabled: bool = attr(name="enable") + priority: int = attr() + maintain_for: int = attr(name="maintainFor") -class TelemetryPump(BaseModel): +class TelemetryPump(BaseXmlModel, tag="Pump"): """Real-time telemetry for auxiliary pump equipment. Auxiliary pumps are separate from filter pumps and used for water features, @@ -376,14 +372,14 @@ class TelemetryPump(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.PUMP - system_id: int = Field(alias="@systemId") - state: PumpState = Field(alias="@pumpState") - speed: int = Field(alias="@pumpSpeed") - last_speed: int = Field(alias="@lastSpeed") - why_on: int = Field(alias="@whyOn") + system_id: int = attr(name="systemId") + state: PumpState = attr(name="pumpState") + speed: int = attr(name="pumpSpeed") + last_speed: int = attr(name="lastSpeed") + why_on: int = attr(name="whyOn") -class TelemetryRelay(BaseModel): +class TelemetryRelay(BaseXmlModel, tag="Relay"): """Real-time telemetry for relay-controlled equipment. Relays provide simple on/off control for lights, water features, and other @@ -397,12 +393,12 @@ class TelemetryRelay(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.RELAY - system_id: int = Field(alias="@systemId") - state: RelayState = Field(alias="@relayState") - why_on: RelayWhyOn = Field(alias="@whyOn") + system_id: int = attr(name="systemId") + state: RelayState = attr(name="relayState") + why_on: RelayWhyOn = attr(name="whyOn") -class TelemetryValveActuator(BaseModel): +class TelemetryValveActuator(BaseXmlModel, tag="ValveActuator"): """Real-time telemetry for valve actuator equipment. Valve actuators control motorized valves for directing water flow. Functionally @@ -416,13 +412,13 @@ class TelemetryValveActuator(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.VALVE_ACTUATOR - system_id: int = Field(alias="@systemId") - state: ValveActuatorState = Field(alias="@valveActuatorState") + system_id: int = attr(name="systemId") + state: ValveActuatorState = attr(name="valveActuatorState") # Valve actuators are actually relays, so we can reuse the RelayWhyOn enum here - why_on: RelayWhyOn = Field(alias="@whyOn") + why_on: RelayWhyOn = attr(name="whyOn") -class TelemetryVirtualHeater(BaseModel): +class TelemetryVirtualHeater(BaseXmlModel, tag="VirtualHeater"): """Real-time telemetry for virtual heater controller. Virtual heater acts as the control logic for one or more physical heaters, @@ -440,13 +436,13 @@ class TelemetryVirtualHeater(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.VIRT_HEATER - system_id: int = Field(alias="@systemId") - current_set_point: int = Field(alias="@Current-Set-Point") - enabled: bool = Field(alias="@enable") - solar_set_point: int = Field(alias="@SolarSetPoint") - mode: HeaterMode = Field(alias="@Mode") - silent_mode: int = Field(alias="@SilentMode") - why_on: int = Field(alias="@whyHeaterIsOn") + system_id: int = attr(name="systemId") + current_set_point: int = attr(name="Current-Set-Point") + enabled: bool = attr(name="enable") + solar_set_point: int = attr(name="SolarSetPoint") + mode: HeaterMode = attr(name="Mode") + silent_mode: int = attr(name="SilentMode") + why_on: int = attr(name="whyHeaterIsOn") type TelemetryType = ( @@ -465,7 +461,7 @@ class TelemetryVirtualHeater(BaseModel): ) -class Telemetry(BaseModel): +class Telemetry(BaseXmlModel, tag="STATUS", search_mode="unordered"): """Complete real-time telemetry snapshot from the OmniLogic controller. Contains the current state of all equipment in the system. Telemetry is requested @@ -492,66 +488,35 @@ class Telemetry(BaseModel): model_config = ConfigDict(from_attributes=True) - version: str = Field(alias="@version") - backyard: TelemetryBackyard = Field(alias="Backyard") - bow: list[TelemetryBoW] = Field(alias="BodyOfWater") - chlorinator: list[TelemetryChlorinator] | None = Field(alias="Chlorinator", default=None) - colorlogic_light: list[TelemetryColorLogicLight] | None = Field(alias="ColorLogic-Light", default=None) - csad: list[TelemetryCSAD] | None = Field(alias="CSAD", default=None) - filter: list[TelemetryFilter] | None = Field(alias="Filter", default=None) - group: list[TelemetryGroup] | None = Field(alias="Group", default=None) - heater: list[TelemetryHeater] | None = Field(alias="Heater", default=None) - pump: list[TelemetryPump] | None = Field(alias="Pump", default=None) - relay: list[TelemetryRelay] | None = Field(alias="Relay", default=None) - valve_actuator: list[TelemetryValveActuator] | None = Field(alias="ValveActuator", default=None) - virtual_heater: list[TelemetryVirtualHeater] | None = Field(alias="VirtualHeater", default=None) + version: str = attr() + backyard: TelemetryBackyard = element() + bow: list[TelemetryBoW] = element(tag="BodyOfWater", default=[]) + chlorinator: list[TelemetryChlorinator] | None = element(tag="Chlorinator", default=None) + colorlogic_light: list[TelemetryColorLogicLight] | None = element(tag="ColorLogic-Light", default=None) + csad: list[TelemetryCSAD] | None = element(tag="CSAD", default=None) + filter: list[TelemetryFilter] | None = element(tag="Filter", default=None) + group: list[TelemetryGroup] | None = element(tag="Group", default=None) + heater: list[TelemetryHeater] | None = element(tag="Heater", default=None) + pump: list[TelemetryPump] | None = element(tag="Pump", default=None) + relay: list[TelemetryRelay] | None = element(tag="Relay", default=None) + valve_actuator: list[TelemetryValveActuator] | None = element(tag="ValveActuator", default=None) + virtual_heater: list[TelemetryVirtualHeater] | None = element(tag="VirtualHeater", default=None) @staticmethod def load_xml(xml: str) -> Telemetry: - @overload - def xml_postprocessor(path: Any, key: Any, value: SupportsInt) -> tuple[Any, SupportsInt]: ... - @overload - def xml_postprocessor(path: Any, key: Any, value: Any) -> tuple[Any, Any]: ... - def xml_postprocessor(path: Any, key: Any, value: SupportsInt | Any) -> tuple[Any, SupportsInt | Any]: - """Post process XML to attempt to convert values to int. - - Pydantic can coerce values natively, but the Omni API returns values as strings of numbers (I.E. "2", "5", etc) and we need them - coerced into int enums. Pydantic only seems to be able to handle one coercion, so it could coerce an int into an Enum, but it - cannot coerce a string into an int and then into the Enum. We help it out a little bit here by preemptively coercing any - string ints into an int, then pydantic handles the int to enum coercion if necessary. - """ - newvalue: SupportsInt | Any - - try: - newvalue = int(value) - except (ValueError, TypeError): - newvalue = value - - return key, newvalue - - data = xml_parse( - xml, - postprocessor=xml_postprocessor, - # Some things will be lists or not depending on if a pool has more than one of that piece of equipment. Here we are coercing - # everything into lists to make the parsing more consistent. This does mean that some things that would normally never be lists - # will become lists (I.E.: Backyard, VirtualHeater), but the upside is that we need far less conditional code to deal with the - # "maybe list maybe not" devices. - force_list=( - OmniType.BOW, - OmniType.CHLORINATOR, - OmniType.CSAD, - OmniType.CL_LIGHT, - OmniType.FILTER, - OmniType.GROUP, - OmniType.HEATER, - OmniType.PUMP, - OmniType.RELAY, - OmniType.VALVE_ACTUATOR, - OmniType.VIRT_HEATER, - ), - ) + """Load telemetry from XML string. + + Args: + xml: XML string containing telemetry data + + Returns: + Parsed Telemetry object + + Raises: + OmniParsingException: If XML parsing or validation fails + """ try: - return Telemetry.model_validate(data["STATUS"]) + return Telemetry.from_xml(xml) except ValidationError as exc: raise OmniParsingException(f"Failed to parse Telemetry: {exc}") from exc diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index b452b70..cd72072 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -507,7 +507,7 @@ class RelayFunction(StrEnum, PrettyEnum): CLEANER_IN_FLOOR = "RLY_CLEANER_IN_FLOOR" -class RelayState(PrettyEnum): +class RelayState(IntEnum, PrettyEnum): OFF = 0 ON = 1 diff --git a/pyproject.toml b/pyproject.toml index 2a1f912..5817281 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pydantic >=2.0.0,<3.0.0", "click >=8.0.0,<8.4.0", "xmltodict >=1.0.2,<2.0.0", + "pydantic-xml>=2.18.0", ] [project.scripts] @@ -42,6 +43,7 @@ profile = "black" python_version = "3.13" plugins = [ "pydantic.mypy", + "pydantic_xml.mypy", ] follow_imports = "silent" strict = true diff --git a/tests/fixtures/issue-144.json b/tests/fixtures/issue-144.json new file mode 100644 index 0000000..2690ca5 --- /dev/null +++ b/tests/fixtures/issue-144.json @@ -0,0 +1,4 @@ +{ + "mspconfig": "\n\n\n \n Percent\n 12 Hour Format\n 600\n yes\n on\n Metric\n Salt\n English\n standard\n Yes\n Yes\n Yes\n Yes\n \n \n MSP Configuration\n 0\n Backyard\n 0\n \n 10\n AirSensor\n SENSOR_AIR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_AIR_TEMP\n 2068\n 4\n 0\n \n \n \n \n 01\n 3\n Pool\n BOW_POOL\n BOW_NO_EQUIPMENT_SHARED\n SHARED_EQUIPMENT_LOW_PRIORITY\n 0\n no\n no\n 13738\n \n 4\n Filter Pump\n BOW_NO_EQUIPMENT_SHARED\n FMT_VARIABLE_SPEED_PUMP\n 100\n 45\n 3000\n 600\n 30\n yes\n 120\n 300\n 300\n yes\n 900\n no\n 35\n no\n 38\n 90\n 0\n FLT_DONT_CHANGE_VALVES\n 45\n 75\n 100\n 80\n 7200\n \n PEO_VSP_SET_SPEED\n \n ACT_FNC_VSP_SET_SPEED\n 2078\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_FLT_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_FLT_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n \n 5\n pH\n CSAD_AUTO\n ACID\n yes\n 7.5\n -1.0\n 7200\n no\n 6.9\n 8.1\n 300\n 540\n 540\n 350\n 950\n 0\n no\n \n PEO_STATUS_GET\n \n ACT_FNC_CSAD_STATUS_GET\n 2077\n 0\n 0\n \n \n \n PEO_REVISION_GET\n \n ACT_FNC_CSAD_REVISION_GET\n 2077\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CSAD_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CSAD_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_CSAD_EQUIPMENT\n \n 8\n ChemSense1\n PET_CSAD\n AQL-CHEM\n yes\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 2055\n 1\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 2055\n 1\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 2055\n 1\n 0\n \n \n \n \n \n \n 6\n Chlorinator\n BOW_NO_EQUIPMENT_SHARED\n yes\n CHLOR_OP_MODE_ORP_AUTO\n 50\n 8\n CELL_TYPE_T15\n SALT_DISPENSING\n 86400\n -1\n \n PEO_INIT\n \n ACT_FNC_CHL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CHL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_CHLORINATOR_EQUIPMENT\n \n 7\n Chlorinator1\n PET_CHLORINATOR\n CHLOR_TYPE_MAIN_PANEL\n yes\n \n PEO_STATUS_GET\n \n ACT_FNC_CHL_STATUS_GET\n 2072\n 0\n 0\n \n \n \n PEO_PARAMS_SET\n \n ACT_FNC_CHL_PARAMS_SET\n 2072\n 0\n 0\n \n \n \n PEO_PAUSE\n \n ACT_FNC_CHL_PAUSE_CONTINUE\n 2072\n 1\n 0\n \n \n \n PEO_CONTINUE\n \n ACT_FNC_CHL_PAUSE_CONTINUE\n 2072\n 2\n 0\n \n \n \n PEO_ERRORS_GET\n \n ACT_FNC_CHL_ERRORS_GET\n 2072\n 0\n 0\n \n \n \n PEO_ALERTS_GET\n \n ACT_FNC_CHL_ALERTS_GET\n 2072\n 0\n 0\n \n \n \n PEO_SUPER_CHLOR_ON\n \n ACT_FNC_CHL_SUPER_CHLOR_SET\n 2072\n 1\n 0\n \n \n \n PEO_SUPER_CHLOR_OFF\n \n ACT_FNC_CHL_SUPER_CHLOR_SET\n 2072\n 0\n 0\n \n \n \n PEO_SALT_CALC_RESTART\n \n ACT_FNC_SALT_CALC_RESTART\n 2072\n 0\n 0\n \n \n \n PEO_POLARITY_REVERSE\n \n ACT_FNC_CHL_RELAY_POLARITY_REVERSE\n 2072\n 0\n 0\n \n \n \n PEO_CELL_RUNTIME_RESTART\n \n ACT_FNC_CHL_CELL_RUNTIME_RESTART\n 2072\n 0\n 0\n \n \n \n \n \n \n 9\n UCL\n COLOR_LOGIC_UCL\n 0\n no\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 2056\n 2\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 2056\n 2\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 2056\n 2\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CLL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CLL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_LIGHT_HAS_BEEN_ON\n \n ACT_FNC_HW_SEC_LIGHTS_HAVE_BEEN_ON\n 0\n 0\n 0\n \n \n \n \n 11\n WaterSensor\n SENSOR_WATER_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_TEMP\n 2067\n 2\n 0\n \n \n \n \n 12\n FlowSensor\n SENSOR_FLOW\n UNITS_ACTIVE_INACTIVE\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_FLOW\n 2065\n 0\n 0\n \n \n \n \n 15\n BOW_NO_EQUIPMENT_SHARED\n yes\n 84\n 104\n 55\n 104\n yes\n no\n 300\n 900\n \n PEO_INIT\n \n ACT_FNC_HEATER_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_HEATER_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_HEATER_EQUIPMENT\n \n 16\n Heat Pump\n PET_HEATER\n HTR_HEAT_PUMP\n yes\n HTR_PRIORITY_1\n HTR_MAINTAINS_PRIORITY_FOR_AS_LONG_AS_VALID\n yes\n 80\n no\n 180\n 46\n 46\n 10\n -1\n \n PEO_TURN_ON\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2051\n 1\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2051\n 1\n 0\n \n \n \n \n \n \n \n \n \n 3\n 4\n 13\n 164\n 45\n 1\n 0\n 9\n 0\n 14\n 127\n 1\n \n \n 3\n 9\n 14\n 164\n 0\n 0\n 30\n 20\n 0\n 22\n 127\n 1\n \n \n 3\n 15\n 17\n 164\n 84\n 1\n 0\n 11\n 0\n 14\n 127\n 1\n \n \n 3\n 15\n 19\n 164\n 65\n 1\n 0\n 14\n 0\n 11\n 127\n 1\n \n \n 3\n 4\n 20\n 164\n 45\n 0\n 0\n 21\n 0\n 10\n 127\n 1\n \n \n 3\n 15\n 21\n 164\n 86\n 0\n 0\n 21\n 30\n 8\n 127\n 1\n \n \n 3\n 4\n 22\n 164\n 45\n 0\n 0\n 15\n 0\n 21\n 127\n 1\n \n \n \n \n MP\n MP\n 2048\n \n \n LVR1\n RELAY\n 2051\n \n \n LVR2\n RELAY\n 2052\n \n \n LVR3\n RELAY\n 2053\n \n \n LVR4\n RELAY\n 2054\n \n \n HVR1\n RELAY\n 2055\n \n \n HVR2\n RELAY\n 2056\n \n \n HVR3\n RELAY\n 2057\n \n \n HVR4\n RELAY\n 2058\n \n \n HVR5\n RELAY\n 2059\n \n \n HVR6\n RELAY\n 2060\n \n \n ACR1\n RELAY\n 2061\n \n \n ACR2\n RELAY\n 2062\n \n \n ACR3\n RELAY\n 2063\n \n \n ACR4\n RELAY\n 2064\n \n \n SNS1\n SENSOR\n 2065\n \n \n SNS2\n SENSOR\n 2066\n \n \n SNS3\n SENSOR\n 2067\n \n \n SNS4\n SENSOR\n 2068\n \n \n SNS5\n SENSOR\n 2069\n \n \n SNS6\n SENSOR\n 2070\n \n \n SNS7\n SENSOR\n 2071\n \n \n CHLR1\n CHLORINATOR\n 2072\n \n \n \n \n RB\n RB\n 2050\n \n \n HVR1\n RELAY\n 2073\n \n \n HVR2\n RELAY\n 2074\n \n \n HVR3\n RELAY\n 2075\n \n \n HVR4\n RELAY\n 2076\n \n \n \n \n VSP\n EPNS\n 2078\n \n \n \n L.Chem SM\n LCSM\n 2077\n \n \n \n 2059497\n\n", + "telemetry": "\n\n \n \n \n \n \n \n \n \n\n" +} diff --git a/tests/fixtures/issue-163.json b/tests/fixtures/issue-163.json new file mode 100644 index 0000000..67ea59b --- /dev/null +++ b/tests/fixtures/issue-163.json @@ -0,0 +1,4 @@ +{ + "mspconfig": "\n\n\n \n Percent\n 24 Hour Format\n -420\n no\n on\n Standard\n Salt\n English\n standard\n Yes\n Yes\n Yes\n Yes\n \n \n MSP Configuration\n 0\n Backyard\n 0\n \n 16\n AirSensor\n SENSOR_AIR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_AIR_TEMP\n 20\n 4\n 0\n \n \n \n \n 01\n 10\n Pool\n BOW_POOL\n BOW_NO_EQUIPMENT_SHARED\n SHARED_EQUIPMENT_LOW_PRIORITY\n 0\n no\n no\n 14000\n \n 11\n Filter Pump\n BOW_NO_EQUIPMENT_SHARED\n FMT_VARIABLE_SPEED_PUMP\n 100\n 18\n 3450\n 600\n 30\n yes\n 180\n 300\n 300\n no\n 900\n no\n 35\n yes\n 36\n 50\n 0\n FLT_DONT_CHANGE_VALVES\n 18\n 60\n 100\n 18\n 7200\n \n PEO_VSP_SET_SPEED\n \n ACT_FNC_VSP_SET_SPEED\n 1\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_FLT_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_FLT_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n \n 12\n Chlorinator\n BOW_NO_EQUIPMENT_SHARED\n no\n CHLOR_OP_MODE_TIMED\n 100\n 16\n CELL_TYPE_TCELLS340\n SALT_DISPENSING\n 86400\n -1\n \n PEO_INIT\n \n ACT_FNC_CHL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CHL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_CHLORINATOR_EQUIPMENT\n \n 13\n Chlorinator1\n PET_CHLORINATOR\n CHLOR_TYPE_MAIN_PANEL\n yes\n \n PEO_STATUS_GET\n \n ACT_FNC_CHL_STATUS_GET\n 24\n 0\n 0\n \n \n \n PEO_PARAMS_SET\n \n ACT_FNC_CHL_PARAMS_SET\n 24\n 0\n 0\n \n \n \n PEO_PAUSE\n \n ACT_FNC_CHL_PAUSE_CONTINUE\n 24\n 1\n 0\n \n \n \n PEO_CONTINUE\n \n ACT_FNC_CHL_PAUSE_CONTINUE\n 24\n 2\n 0\n \n \n \n PEO_ERRORS_GET\n \n ACT_FNC_CHL_ERRORS_GET\n 24\n 0\n 0\n \n \n \n PEO_ALERTS_GET\n \n ACT_FNC_CHL_ALERTS_GET\n 24\n 0\n 0\n \n \n \n PEO_SUPER_CHLOR_ON\n \n ACT_FNC_CHL_SUPER_CHLOR_SET\n 24\n 1\n 0\n \n \n \n PEO_SUPER_CHLOR_OFF\n \n ACT_FNC_CHL_SUPER_CHLOR_SET\n 24\n 0\n 0\n \n \n \n PEO_SALT_CALC_RESTART\n \n ACT_FNC_SALT_CALC_RESTART\n 24\n 0\n 0\n \n \n \n PEO_POLARITY_REVERSE\n \n ACT_FNC_CHL_RELAY_POLARITY_REVERSE\n 24\n 0\n 0\n \n \n \n PEO_CELL_RUNTIME_RESTART\n \n ACT_FNC_CHL_CELL_RUNTIME_RESTART\n 24\n 0\n 0\n \n \n \n \n \n \n 14\n Fountain\n PMP_SINGLE_SPEED\n PMP_FOUNTAIN\n yes\n 100\n no\n 1800\n no\n 180\n 3450\n 600\n 18\n 100\n 100\n 100\n 100\n 100\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 7\n 4\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 7\n 4\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_PUMP_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_PUMP_TEARDOWN\n 0\n 0\n 0\n \n \n \n \n 15\n Color Lights\n COLOR_LOGIC_4_0\n 0\n no\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 6\n 2\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 6\n 2\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 6\n 2\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CLL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CLL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_LIGHT_HAS_BEEN_ON\n \n ACT_FNC_HW_SEC_LIGHTS_HAVE_BEEN_ON\n 0\n 0\n 0\n \n \n \n \n 17\n WaterSensor\n SENSOR_WATER_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_TEMP\n 19\n 2\n 0\n \n \n \n \n 18\n FlowSensor\n SENSOR_FLOW\n UNITS_ACTIVE_INACTIVE\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_FLOW\n 17\n 0\n 0\n \n \n \n \n \n \n \n 10\n 11\n 31\n 164\n 60\n 1\n 0\n 8\n 0\n 16\n 127\n 1\n \n \n 10\n 12\n 38\n 164\n 100\n 1\n 0\n 10\n 0\n 16\n 127\n 1\n \n \n \n \n 22\n 1\n 15\n 0\n 2\n 1\n \n \n 23\n 2\n 11\n 1\n 268435440\n 1\n \n \n 24\n 3\n 12\n 2\n 0\n 0\n \n \n 25\n 4\n 14\n 3\n 0\n 1\n \n \n 37\n 5\n 36\n 4\n 268435455\n 1\n \n \n \n \n 36\n Theme1\n 0\n \n TurnOnOffForGroup\n \n 10\n 11\n 70\n 0\n \n \n \n SetUISuperCHLORCmd\n \n 10\n 12\n 0\n \n \n \n TurnOnOffForGroup\n \n 10\n 14\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 10\n 15\n 263168\n 0\n \n \n \n \n \n \n VSP\n EPNS\n 1\n \n \n \n OPLMP\n OPLMP\n 2\n \n \n LVR1\n RELAY\n 3\n \n \n LVR2\n RELAY\n 4\n \n \n HVR1\n RELAY\n 5\n \n \n HVR2\n RELAY\n 6\n \n \n HVR3\n RELAY\n 7\n \n \n HVR4\n RELAY\n 8\n \n \n HVR5\n RELAY\n 9\n \n \n HVR6\n RELAY\n 10\n \n \n HVR7\n RELAY\n 11\n \n \n HVR8\n RELAY\n 12\n \n \n ACR1\n RELAY\n 13\n \n \n ACR2\n RELAY\n 14\n \n \n ACR3\n RELAY\n 15\n \n \n ACR4\n RELAY\n 16\n \n \n SNS1\n SENSOR\n 17\n \n \n SNS2\n SENSOR\n 18\n \n \n SNS3\n SENSOR\n 19\n \n \n SNS4\n SENSOR\n 20\n \n \n SNS5\n SENSOR\n 21\n \n \n SNS6\n SENSOR\n 22\n \n \n SNS7\n SENSOR\n 23\n \n \n CHLR1\n CHLORINATOR\n 24\n \n \n \n \n 1730790\n\n", + "telemetry": "\n\n \n \n \n \n \n \n \n\n" +} diff --git a/tests/fixtures/issue-60.json b/tests/fixtures/issue-60.json new file mode 100644 index 0000000..6d0c502 --- /dev/null +++ b/tests/fixtures/issue-60.json @@ -0,0 +1,4 @@ +{ + "mspconfig": "\n\n\n \n Percent\n 12 Hour Format\n Standard\n Salt\n English\n standard\n No\n Yes\n Yes\n Yes\n \n \n MSP Configuration\n 0\n Backyard\n 0\n \n 16\n AirSensor\n SENSOR_AIR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_AIR_TEMP\n 19\n 4\n 0\n \n \n \n \n 27\n Yard Lights\n RLY_HIGH_VOLTAGE_RELAY\n RLY_LIGHT\n no\n no\n 0\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 10\n 16\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 10\n 16\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 10\n 16\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_LV_RELAY_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_LV_RELAY_TEARDOWN\n 0\n 0\n 0\n \n \n \n \n 01\n 1\n Pool\n BOW_POOL\n BOW_SHARED_EQUIPMENT\n SHARED_EQUIPMENT_LOW_PRIORITY\n 8\n no\n no\n 0\n \n 3\n Filter Pump\n BOW_SHARED_EQUIPMENT\n FMT_VARIABLE_SPEED_PUMP\n 100\n 18\n 3450\n 600\n 30\n no\n 180\n 300\n 300\n no\n 900\n no\n 35\n no\n 38\n 50\n 1800\n FLT_DONT_CHANGE_VALVES\n 50\n 75\n 90\n 65\n 7200\n \n PEO_VSP_SET_SPEED\n \n ACT_FNC_VSP_SET_SPEED\n 24\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_FLT_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_FLT_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n PEO_SET_VALVES_FOR_SPILLOVER\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 13\n 2\n 0\n \n \n ACT_FNC_HW_ACTIVATE_VALVE\n 12\n 1\n 1\n \n \n \n PEO_SET_VALVES_FOR_FILTER\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 13\n 2\n 0\n \n \n ACT_FNC_HW_ACTIVATE_VALVE\n 12\n 1\n 0\n \n \n \n \n 4\n BOW_SHARED_EQUIPMENT\n no\n 84\n 86\n 104\n 65\n 104\n no\n no\n 300\n 900\n \n PEO_INIT\n \n ACT_FNC_HEATER_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_HEATER_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_HEATER_EQUIPMENT\n \n 5\n Gas\n PET_HEATER\n HTR_GAS\n no\n HTR_PRIORITY_2\n HTR_MAINTAINS_PRIORITY_FOR_AS_LONG_AS_VALID\n yes\n 80\n no\n 180\n 8\n 2\n -1\n 12\n \n PEO_TURN_ON\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2\n 1\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2\n 1\n 0\n \n \n \n \n \n PEO_HEATER_EQUIPMENT\n \n 18\n Solar\n PET_HEATER\n HTR_SOLAR\n yes\n HTR_PRIORITY_1\n HTR_MAINTAINS_PRIORITY_FOR_8HRS\n yes\n 75\n yes\n 180\n 8\n 2\n 20\n yes\n 19\n \n PEO_OPEN_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 15\n 8\n 1\n \n \n \n PEO_CLOSE_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 15\n 8\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_VA_RELAY\n 15\n 8\n 0\n \n \n \n \n \n \n 6\n WaterSensor\n SENSOR_WATER_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_TEMP\n 18\n 2\n 0\n \n \n \n \n 20\n SolarSensor\n SENSOR_SOLAR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_SOLAR_TEMP\n 20\n 5\n 0\n \n \n \n \n 22\n Water Ft\n RLY_VALVE_ACTUATOR\n RLY_WATER_FEATURE\n no\n no\n 1800\n 85\n \n PEO_INIT\n \n ACT_FNC_LV_RELAY_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_LV_RELAY_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n PEO_OPEN_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 14\n 4\n 1\n \n \n \n PEO_CLOSE_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 14\n 4\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_VA_RELAY\n 14\n 4\n 0\n \n \n \n \n 23\n Pool Light\n COLOR_LOGIC_UCL\n 0\n no\n yes\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 6\n 1\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 6\n 1\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 6\n 1\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CLL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CLL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_LIGHT_HAS_BEEN_ON\n \n ACT_FNC_HW_SEC_LIGHTS_HAVE_BEEN_ON\n 0\n 0\n 0\n \n \n \n PEO_V2_TOGGLE\n \n ACT_FNC_V2_TOGGLE_RELAY\n 6\n 1\n 1\n \n \n \n \n 24\n Sheer Lts\n COLOR_LOGIC_UCL\n 0\n no\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 9\n 8\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 9\n 8\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 9\n 8\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CLL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CLL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_LIGHT_HAS_BEEN_ON\n \n ACT_FNC_HW_SEC_LIGHTS_HAVE_BEEN_ON\n 0\n 0\n 0\n \n \n \n \n 36\n UV Ozone\n RLY_HIGH_VOLTAGE_RELAY\n RLY_ACCESSORY\n no\n no\n 1800\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 11\n 32\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 11\n 32\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_LV_RELAY_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_LV_RELAY_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 11\n 32\n 0\n \n \n \n \n \n 02\n 8\n Spa\n BOW_SPA\n BOW_SHARED_EQUIPMENT\n SHARED_EQUIPMENT_HIGH_PRIORITY\n 1\n no\n no\n 0\n \n 10\n Filter Pump\n BOW_SHARED_EQUIPMENT\n FMT_VARIABLE_SPEED_PUMP\n 100\n 18\n 3450\n 600\n 30\n no\n 180\n 300\n 300\n no\n 900\n no\n 35\n no\n 38\n 50\n 1800\n FLT_DONT_CHANGE_VALVES\n 50\n 75\n 90\n 100\n 7200\n \n PEO_VSP_SET_SPEED\n \n ACT_FNC_VSP_SET_SPEED\n 24\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_FLT_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_FLT_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n PEO_SET_VALVES_FOR_SPILLOVER\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 13\n 2\n 0\n \n \n ACT_FNC_HW_ACTIVATE_VALVE\n 12\n 1\n 1\n \n \n \n PEO_SET_VALVES_FOR_FILTER\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 13\n 2\n 1\n \n \n ACT_FNC_HW_ACTIVATE_VALVE\n 12\n 1\n 1\n \n \n \n \n 11\n BOW_SHARED_EQUIPMENT\n no\n 99\n 101\n 104\n 65\n 104\n no\n no\n 300\n 900\n \n PEO_INIT\n \n ACT_FNC_HEATER_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_HEATER_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_HEATER_EQUIPMENT\n \n 12\n Gas\n PET_HEATER\n HTR_GAS\n yes\n HTR_PRIORITY_1\n HTR_MAINTAINS_PRIORITY_FOR_AS_LONG_AS_VALID\n yes\n 80\n no\n 180\n 8\n 2\n -1\n 5\n \n PEO_TURN_ON\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2\n 1\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2\n 1\n 0\n \n \n \n \n \n PEO_HEATER_EQUIPMENT\n \n 19\n Solar\n PET_HEATER\n HTR_SOLAR\n no\n HTR_PRIORITY_2\n HTR_MAINTAINS_PRIORITY_FOR_8HRS\n yes\n 75\n yes\n 180\n 8\n 2\n 38\n yes\n 18\n \n PEO_OPEN_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 15\n 8\n 1\n \n \n \n PEO_CLOSE_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 15\n 8\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_VA_RELAY\n 15\n 8\n 0\n \n \n \n \n \n \n 13\n Spa Light\n COLOR_LOGIC_UCL\n 0\n no\n yes\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 7\n 2\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 7\n 2\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 7\n 2\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CLL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CLL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_LIGHT_HAS_BEEN_ON\n \n ACT_FNC_HW_SEC_LIGHTS_HAVE_BEEN_ON\n 0\n 0\n 0\n \n \n \n PEO_V2_TOGGLE\n \n ACT_FNC_V2_TOGGLE_RELAY\n 7\n 2\n 1\n \n \n \n \n 14\n Jet\n PMP_VARIABLE_SPEED_PUMP\n PMP_JETS\n no\n 100\n no\n 1800\n no\n 180\n 3450\n 600\n 18\n 100\n 75\n 100\n 100\n 50\n \n PEO_VSP_SET_SPEED\n \n ACT_FNC_VSP_SET_SPEED\n 25\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_PUMP_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_PUMP_TEARDOWN\n 0\n 0\n 0\n \n \n \n \n 15\n Blower\n RLY_HIGH_VOLTAGE_RELAY\n RLY_BLOWER\n no\n no\n 1800\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 8\n 4\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 8\n 4\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_LV_RELAY_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_LV_RELAY_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 8\n 4\n 0\n \n \n \n \n 17\n WaterSensor\n SENSOR_WATER_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_TEMP\n 18\n 2\n 0\n \n \n \n \n 21\n SolarSensor\n SENSOR_SOLAR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_SOLAR_TEMP\n 20\n 5\n 0\n \n \n \n \n 38\n SolarSensor\n SENSOR_SOLAR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_SOLAR_TEMP\n 20\n 5\n 0\n \n \n \n \n \n \n \n 1\n 3\n 7\n 164\n 55\n 1\n 0\n 8\n 0\n 12\n 127\n 1\n \n \n 1\n 23\n 29\n 164\n 263180\n 0\n 20\n 17\n 40\n 23\n 127\n 1\n \n \n 8\n 13\n 30\n 164\n 263180\n 0\n 20\n 17\n 40\n 20\n 127\n 1\n \n \n 0\n 27\n 31\n 164\n 1\n 0\n 43\n 16\n 40\n 23\n 127\n 1\n \n \n 1\n 36\n 37\n 164\n 1\n 1\n 0\n 0\n 59\n 23\n 127\n 1\n \n \n 1\n 4\n 39\n 164\n 21588\n 1\n 0\n 11\n 0\n 16\n 31\n 1\n \n \n 1\n 4\n 42\n 164\n 22102\n 1\n 0\n 10\n 0\n 16\n 96\n 1\n \n \n 1\n 3\n 43\n 164\n 65\n 1\n 0\n 12\n 0\n 16\n 127\n 1\n \n \n \n \n 28\n 1\n 3\n 0\n 268435440\n 1\n \n \n 34\n 2\n 33\n 1\n 268435455\n 1\n \n \n 41\n 3\n 40\n 2\n 268435455\n 1\n \n \n \n \n 33\n All Off\n 0\n \n TurnOnOffForGroup\n \n 0\n 27\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 3\n 0\n 0\n \n \n \n SetHeaterScheduleCmd\n \n 1\n 4\n 90\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n SetUITemporaryHeaterPriorityCmd\n \n 1\n 5\n 18\n -1\n -1\n -1\n \n \n \n SetUITemporaryHeaterMaintainPriorityCmd\n \n 1\n 4\n 24\n 8\n 255\n 255\n 255\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 4\n 0\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 5\n 1\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 18\n 0\n \n \n \n SetSolarScheduleCmd\n \n 1\n 4\n 71\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 22\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 23\n 263180\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 24\n 263168\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 10\n 0\n 0\n \n \n \n SetHeaterScheduleCmd\n \n 8\n 11\n 70\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n SetUITemporaryHeaterPriorityCmd\n \n 8\n 12\n 19\n -1\n -1\n -1\n \n \n \n SetUITemporaryHeaterMaintainPriorityCmd\n \n 8\n 11\n 24\n 8\n 255\n 255\n 255\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 11\n 0\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 12\n 1\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 19\n 0\n \n \n \n SetSolarScheduleCmd\n \n 8\n 11\n 70\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 13\n 263180\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 14\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 15\n 0\n 0\n \n \n \n \n 40\n 4th of July\n 0\n \n TurnOnOffForGroup\n \n 0\n 27\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 3\n 85\n 0\n \n \n \n SetHeaterScheduleAltCmd\n \n 1\n 4\n 85\n 85\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n SetUITemporaryHeaterPriorityCmd\n \n 1\n 18\n 5\n -1\n -1\n -1\n \n \n \n SetUITemporaryHeaterMaintainPriorityCmd\n \n 1\n 4\n 8\n 24\n 255\n 255\n 255\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 4\n 0\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 5\n 0\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 18\n 1\n \n \n \n TurnOnOffForGroup\n \n 1\n 22\n 1\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 23\n 263182\n 1\n \n \n \n TurnOnOffForGroup\n \n 1\n 24\n 263182\n 1\n \n \n \n TurnOnOffForGroup\n \n 1\n 36\n 1\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 10\n 0\n 0\n \n \n \n SetHeaterScheduleAltCmd\n \n 8\n 11\n 100\n 101\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n SetUITemporaryHeaterPriorityCmd\n \n 8\n 12\n 19\n -1\n -1\n -1\n \n \n \n SetUITemporaryHeaterMaintainPriorityCmd\n \n 8\n 11\n 24\n 8\n 255\n 255\n 255\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 11\n 0\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 12\n 1\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 19\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 13\n 263182\n 1\n \n \n \n TurnOnOffForGroup\n \n 8\n 14\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 15\n 0\n 0\n \n \n \n \n \n \n MP\n MP\n 1\n \n \n LVR1\n RELAY\n 2\n \n \n LVR2\n RELAY\n 3\n \n \n LVR3\n RELAY\n 4\n \n \n LVR4\n RELAY\n 5\n \n \n HVR1\n RELAY\n 6\n \n \n HVR2\n RELAY\n 7\n \n \n HVR3\n RELAY\n 8\n \n \n HVR4\n RELAY\n 9\n \n \n HVR5\n RELAY\n 10\n \n \n HVR6\n RELAY\n 11\n \n \n ACR1\n RELAY\n 12\n \n \n ACR2\n RELAY\n 13\n \n \n ACR3\n RELAY\n 14\n \n \n ACR4\n RELAY\n 15\n \n \n SNS1\n SENSOR\n 16\n \n \n SNS2\n SENSOR\n 17\n \n \n SNS3\n SENSOR\n 18\n \n \n SNS4\n SENSOR\n 19\n \n \n SNS5\n SENSOR\n 20\n \n \n SNS6\n SENSOR\n 21\n \n \n SNS7\n SENSOR\n 22\n \n \n CHLR1\n CHLORINATOR\n 23\n \n \n \n \n VSP\n EPNS\n 24\n \n \n \n VSP\n EPNS\n 25\n \n \n \n 5483444\n\n", + "telemetry": "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n" +} diff --git a/tests/test_chlorinator_bitmask.py b/tests/test_chlorinator_bitmask.py index 7bf4425..74f4dad 100644 --- a/tests/test_chlorinator_bitmask.py +++ b/tests/test_chlorinator_bitmask.py @@ -13,17 +13,17 @@ def test_chlorinator_status_decoding() -> None: # Bit 7: K2_ACTIVE (128) # Total: 2 + 4 + 128 = 134 data = { - "@systemId": 5, - "@status": 134, - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0, - "@chlrError": 0, - "@scMode": 0, - "@operatingState": 1, - "@Timed-Percent": 70, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 134, + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0, + "chlr_error_raw": 0, + "sc_mode": 0, + "operating_state": 1, + "timed_percent": 70, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -47,16 +47,16 @@ def test_chlorinator_alert_decoding() -> None: # Create a chlorinator with chlrAlert = 32 (0b00100000) # Bit 5: CELL_TEMP_SCALEBACK (32) data = { - "@systemId": 5, - "@status": 2, # ALERT_PRESENT - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 32, - "@chlrError": 0, - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 2, # ALERT_PRESENT + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 32, + "chlr_error_raw": 0, + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -77,16 +77,16 @@ def test_chlorinator_error_decoding() -> None: # Bit 8: K1_RELAY_SHORT (256) # Total: 1 + 256 = 257 data = { - "@systemId": 5, - "@status": 1, # ERROR_PRESENT - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0, - "@chlrError": 257, - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 1, # ERROR_PRESENT + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0, + "chlr_error_raw": 257, + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -104,16 +104,16 @@ def test_chlorinator_error_decoding() -> None: def test_chlorinator_no_flags() -> None: """Test chlorinator with no status/alert/error flags set.""" data = { - "@systemId": 5, - "@status": 0, - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0, - "@chlrError": 0, - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 0, + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0, + "chlr_error_raw": 0, + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -133,16 +133,16 @@ def test_chlorinator_complex_alerts() -> None: # Bit 6: BOARD_TEMP_HIGH (64) # Total: 1 + 2 + 64 = 67 data = { - "@systemId": 5, - "@status": 2, - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 67, - "@chlrError": 0, - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 2, + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 67, + "chlr_error_raw": 0, + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -158,16 +158,16 @@ def test_chlorinator_all_status_flags() -> None: """Test chlorinator with all status flags set.""" # status = 255 (0b11111111) - all 8 bits set data = { - "@systemId": 5, - "@status": 255, - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0, - "@chlrError": 0, - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 255, + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0, + "chlr_error_raw": 0, + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) diff --git a/tests/test_chlorinator_multibit.py b/tests/test_chlorinator_multibit.py index 81d2453..74ef19e 100644 --- a/tests/test_chlorinator_multibit.py +++ b/tests/test_chlorinator_multibit.py @@ -10,16 +10,16 @@ def test_cell_temp_high_special_case() -> None: # Cell Water Temp bits 5:4 = 11 (both CELL_TEMP_LOW and CELL_TEMP_SCALEBACK set) # This should be replaced with "CELL_TEMP_HIGH" data = { - "@systemId": 5, - "@status": 2, # ALERT_PRESENT - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0b11_0000, # bits 5:4 = 11 - "@chlrError": 0, - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 2, # ALERT_PRESENT + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0b11_0000, # bits 5:4 = 11 + "chlr_error_raw": 0, + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -34,16 +34,16 @@ def test_cell_temp_high_special_case() -> None: def test_cell_temp_low_only() -> None: """Test that CELL_TEMP_LOW appears normally when only that bit is set.""" data = { - "@systemId": 5, - "@status": 2, - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0b01_0000, # bits 5:4 = 01 - "@chlrError": 0, - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 2, + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0b01_0000, # bits 5:4 = 01 + "chlr_error_raw": 0, + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -56,16 +56,16 @@ def test_cell_temp_low_only() -> None: def test_cell_temp_scaleback_only() -> None: """Test that CELL_TEMP_SCALEBACK appears normally when only that bit is set.""" data = { - "@systemId": 5, - "@status": 2, - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0b10_0000, # bits 5:4 = 10 - "@chlrError": 0, - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 2, + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0b10_0000, # bits 5:4 = 10 + "chlr_error_raw": 0, + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -80,16 +80,16 @@ def test_cell_comm_loss_special_case() -> None: # Cell Error bits 13:12 = 11 (both CELL_ERROR_TYPE and CELL_ERROR_AUTH set) # This should be replaced with "CELL_COMM_LOSS" data = { - "@systemId": 5, - "@status": 1, # ERROR_PRESENT - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0, - "@chlrError": 0b11_000000000000, # bits 13:12 = 11 - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 1, # ERROR_PRESENT + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0, + "chlr_error_raw": 0b11_000000000000, # bits 13:12 = 11 + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -104,16 +104,16 @@ def test_cell_comm_loss_special_case() -> None: def test_cell_error_type_only() -> None: """Test that CELL_ERROR_TYPE appears normally when only that bit is set.""" data = { - "@systemId": 5, - "@status": 1, - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0, - "@chlrError": 0b01_000000000000, # bits 13:12 = 01 - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 1, + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0, + "chlr_error_raw": 0b01_000000000000, # bits 13:12 = 01 + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -126,16 +126,16 @@ def test_cell_error_type_only() -> None: def test_cell_error_auth_only() -> None: """Test that CELL_ERROR_AUTH appears normally when only that bit is set.""" data = { - "@systemId": 5, - "@status": 1, - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0, - "@chlrError": 0b10_000000000000, # bits 13:12 = 10 - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 1, + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0, + "chlr_error_raw": 0b10_000000000000, # bits 13:12 = 10 + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -149,16 +149,16 @@ def test_combined_with_other_flags() -> None: """Test special cases work correctly when combined with other flags.""" # Multiple alerts including CELL_TEMP_HIGH data = { - "@systemId": 5, - "@status": 2, - "@instantSaltLevel": 3000, - "@avgSaltLevel": 3000, - "@chlrAlert": 0x31, # SALT_LOW + CELL_TEMP_HIGH (0b00110001) - "@chlrError": 0, - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 2, + "instant_salt_level": 3000, + "avg_salt_level": 3000, + "chlr_alert_raw": 0x31, # SALT_LOW + CELL_TEMP_HIGH (0b00110001) + "chlr_error_raw": 0, + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -170,16 +170,16 @@ def test_combined_with_other_flags() -> None: # Multiple errors including CELL_COMM_LOSS data = { - "@systemId": 5, - "@status": 1, - "@instantSaltLevel": 4082, - "@avgSaltLevel": 4042, - "@chlrAlert": 0, - "@chlrError": 0x3001, # CURRENT_SENSOR_SHORT + CELL_COMM_LOSS - "@scMode": 0, - "@operatingState": 1, - "@operatingMode": 1, - "@enable": True, + "system_id": 5, + "status_raw": 1, + "instant_salt_level": 4082, + "avg_salt_level": 4042, + "chlr_alert_raw": 0, + "chlr_error_raw": 0x3001, # CURRENT_SENSOR_SHORT + CELL_COMM_LOSS + "sc_mode": 0, + "operating_state": 1, + "operating_mode": 1, + "enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) diff --git a/tests/test_filter_pump.py b/tests/test_filter_pump.py index 230dd25..31042dd 100644 --- a/tests/test_filter_pump.py +++ b/tests/test_filter_pump.py @@ -55,14 +55,14 @@ def sample_filter_telemetry(): return TelemetryFilter( omni_type=OmniType.FILTER, **{ - "@systemId": 8, - "@filterState": 1, - "@filterSpeed": 60, - "@valvePosition": 1, - "@whyFilterIsOn": 14, - "@reportedFilterSpeed": 60, - "@power": 500, - "@lastSpeed": 50, + "system_id": 8, + "state": 1, + "speed": 60, + "valve_position": 1, + "why_on": 14, + "reported_speed": 60, + "power": 500, + "last_speed": 50, }, ) @@ -94,11 +94,11 @@ def sample_pump_telemetry(): return TelemetryPump( omni_type=OmniType.PUMP, **{ - "@systemId": 15, - "@pumpState": 1, - "@pumpSpeed": 60, - "@lastSpeed": 50, - "@whyOn": 11, + "system_id": 15, + "state": 1, + "speed": 60, + "last_speed": 50, + "why_on": 11, }, ) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..7ca00f2 --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,431 @@ +"""Tests for validating real-world fixture data from GitHub issues. + +This test suite uses JSON fixture files from tests/fixtures/ directory, which contain +actual MSPConfig and Telemetry XML data from real OmniLogic hardware. Each fixture +represents a specific configuration reported in GitHub issues. + +The tests validate: +- MSPConfig: System IDs, equipment names, counts, and types +- Telemetry: System IDs, state values, and telemetry counts +""" + +from __future__ import annotations + +import json +import pathlib +from typing import Any + +import pytest +from pytest_subtests import SubTests + +from pyomnilogic_local.models.mspconfig import MSPConfig +from pyomnilogic_local.models.telemetry import Telemetry +from pyomnilogic_local.omnitypes import OmniType + +# Path to fixtures directory +FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" + + +def load_fixture(filename: str) -> dict[str, str]: + """Load a fixture file and return the mspconfig and telemetry XML strings. + + Args: + filename: Name of the fixture file (e.g., "issue-60.json") + + Returns: + Dictionary with 'mspconfig' and 'telemetry' keys containing XML strings + """ + fixture_path = FIXTURES_DIR / filename + with open(fixture_path, encoding="utf-8") as f: + data = json.load(f) + return data + + +def get_equipment_by_type(msp: MSPConfig, omni_type: OmniType) -> list[Any]: + """Get all equipment of a specific type from MSPConfig. + + Args: + msp: Parsed MSPConfig + omni_type: Type of equipment to find + + Returns: + List of equipment matching the type + """ + equipment = [] + # Check backyard-level equipment + for attr_name in ("relay", "sensor", "colorlogic_light"): + if items := getattr(msp.backyard, attr_name, None): + for item in items: + if item.omni_type == omni_type: + equipment.append(item) + + # Check BoW-level equipment + if msp.backyard.bow: # pylint: disable=too-many-nested-blocks + for bow in msp.backyard.bow: + for attr_name in ("filter", "heater", "pump", "relay", "sensor", "colorlogic_light", "chlorinator"): + if items := getattr(bow, attr_name, None): + # Handle single items or lists + items_list = items if isinstance(items, list) else [items] + for item in items_list: + if item.omni_type == omni_type: + equipment.append(item) + # Check child equipment (e.g., heater equipment within virtual heater) + if hasattr(item, "heater_equipment") and item.heater_equipment: + for child in item.heater_equipment: + if child.omni_type == omni_type: + equipment.append(child) + return equipment + + +class TestIssue144: + """Tests for issue-144.json fixture. + + System configuration: + - 1 Body of Water (Pool) + - ColorLogic UCL light + - Heat pump + - Filter pump + """ + + @pytest.fixture + def fixture_data(self) -> dict[str, str]: + """Load issue-144 fixture data.""" + return load_fixture("issue-144.json") + + def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test MSPConfig parsing for issue-144.""" + msp = MSPConfig.load_xml(fixture_data["mspconfig"]) + + with subtests.test(msg="backyard exists"): + assert msp.backyard is not None + assert msp.backyard.name == "Backyard" + + with subtests.test(msg="body of water count"): + assert msp.backyard.bow is not None + assert len(msp.backyard.bow) == 1 + + with subtests.test(msg="pool configuration"): + assert msp.backyard.bow is not None + pool = msp.backyard.bow[0] + assert pool.system_id == 3 + assert pool.name == "Pool" + assert pool.omni_type == OmniType.BOW_MSP + + with subtests.test(msg="filter configuration"): + filters = get_equipment_by_type(msp, OmniType.FILTER) + assert len(filters) == 1 + assert filters[0].system_id == 4 + assert filters[0].name == "Filter Pump" + + with subtests.test(msg="heater equipment configuration"): + heaters = get_equipment_by_type(msp, OmniType.HEATER_EQUIP) + assert len(heaters) == 1 + assert heaters[0].system_id == 16 + assert heaters[0].name == "Heat Pump" + + with subtests.test(msg="colorlogic light configuration"): + lights = get_equipment_by_type(msp, OmniType.CL_LIGHT) + assert len(lights) == 1 + assert lights[0].system_id == 9 + assert lights[0].name == "UCL" + + def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test Telemetry parsing for issue-144.""" + telem = Telemetry.from_xml(fixture_data["telemetry"]) + + with subtests.test(msg="backyard telemetry"): + assert telem.backyard is not None + assert telem.backyard.system_id == 0 + assert telem.backyard.air_temp == 66 + + with subtests.test(msg="body of water telemetry"): + assert len(telem.bow) == 1 + pool = telem.bow[0] + assert pool.system_id == 3 + assert pool.water_temp == -1 # No valid reading + + with subtests.test(msg="filter telemetry"): + assert telem.filter is not None + assert len(telem.filter) == 1 + filter_telem = telem.filter[0] + assert filter_telem.system_id == 4 + + with subtests.test(msg="virtual heater telemetry"): + assert telem.virtual_heater is not None + assert len(telem.virtual_heater) == 1 + vh = telem.virtual_heater[0] + assert vh.system_id == 15 + assert vh.current_set_point == 82 + + with subtests.test(msg="heater equipment telemetry"): + assert telem.heater is not None + assert len(telem.heater) == 1 + heater = telem.heater[0] + assert heater.system_id == 16 + + with subtests.test(msg="colorlogic light telemetry"): + assert telem.colorlogic_light is not None + assert len(telem.colorlogic_light) == 1 + light = telem.colorlogic_light[0] + assert light.system_id == 9 + + +class TestIssue163: + """Tests for issue-163.json fixture. + + System configuration: + - 1 Body of Water (Pool) + - Salt chlorinator + - Variable speed pump + - Fountain pump + """ + + @pytest.fixture + def fixture_data(self) -> dict[str, str]: + """Load issue-163 fixture data.""" + return load_fixture("issue-163.json") + + def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test MSPConfig parsing for issue-163.""" + msp = MSPConfig.load_xml(fixture_data["mspconfig"]) + + with subtests.test(msg="backyard exists"): + assert msp.backyard is not None + assert msp.backyard.name == "Backyard" + + with subtests.test(msg="body of water count"): + assert msp.backyard.bow is not None + assert len(msp.backyard.bow) == 1 + + with subtests.test(msg="pool configuration"): + assert msp.backyard.bow is not None + pool = msp.backyard.bow[0] + assert pool.system_id == 10 + assert pool.name == "Pool" + assert pool.omni_type == OmniType.BOW_MSP + + with subtests.test(msg="filter configuration"): + filters = get_equipment_by_type(msp, OmniType.FILTER) + assert len(filters) == 1 + assert filters[0].system_id == 11 + assert filters[0].name == "Filter Pump" + + with subtests.test(msg="chlorinator configuration"): + chlorinators = get_equipment_by_type(msp, OmniType.CHLORINATOR) + assert len(chlorinators) == 1 + assert chlorinators[0].system_id == 12 + assert chlorinators[0].name == "Chlorinator" + + with subtests.test(msg="pump configuration"): + pumps = get_equipment_by_type(msp, OmniType.PUMP) + assert len(pumps) == 1 + assert pumps[0].system_id == 14 + assert pumps[0].name == "Fountain" + + with subtests.test(msg="backyard sensors"): + assert msp.backyard.sensor is not None + assert len(msp.backyard.sensor) == 1 + assert msp.backyard.sensor[0].system_id == 1 + + def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test Telemetry parsing for issue-163.""" + telem = Telemetry.from_xml(fixture_data["telemetry"]) + + with subtests.test(msg="backyard telemetry"): + assert telem.backyard is not None + assert telem.backyard.system_id == 0 + assert telem.backyard.air_temp == 110 + + with subtests.test(msg="body of water telemetry"): + assert len(telem.bow) == 1 + pool = telem.bow[0] + assert pool.system_id == 10 + assert pool.water_temp == 84 + + with subtests.test(msg="filter telemetry"): + assert telem.filter is not None + assert len(telem.filter) == 1 + filter_telem = telem.filter[0] + assert filter_telem.system_id == 11 + assert filter_telem.speed == 60 + assert filter_telem.state.name == "ON" + + with subtests.test(msg="chlorinator telemetry"): + assert telem.chlorinator is not None + assert len(telem.chlorinator) == 1 + chlor = telem.chlorinator[0] + assert chlor.system_id == 12 + assert chlor.status_raw == 68 + assert chlor.avg_salt_level == 2942 + + with subtests.test(msg="pump telemetry"): + assert telem.pump is not None + assert len(telem.pump) == 1 + pump = telem.pump[0] + assert pump.system_id == 14 + assert pump.speed == 0 + assert pump.state.name == "OFF" + + +class TestIssue60: + """Tests for issue-60.json fixture. + + System configuration: + - 2 Bodies of Water (Pool and Spa) + - Multiple lights, relays, pumps, heaters + - ColorLogic lights with V2 support + - Solar and gas heaters + """ + + @pytest.fixture + def fixture_data(self) -> dict[str, str]: + """Load issue-60 fixture data.""" + return load_fixture("issue-60.json") + + def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test MSPConfig parsing for issue-60.""" + msp = MSPConfig.load_xml(fixture_data["mspconfig"]) + + with subtests.test(msg="backyard exists"): + assert msp.backyard is not None + assert msp.backyard.name == "Backyard" + + with subtests.test(msg="body of water count"): + assert msp.backyard.bow is not None + assert len(msp.backyard.bow) == 2 + + with subtests.test(msg="pool configuration"): + assert msp.backyard.bow is not None + pool = msp.backyard.bow[0] + assert pool.system_id == 1 + assert pool.name == "Pool" + assert pool.omni_type == OmniType.BOW_MSP + + with subtests.test(msg="spa configuration"): + assert msp.backyard.bow is not None + spa = msp.backyard.bow[1] + assert spa.system_id == 8 + assert spa.name == "Spa" + + with subtests.test(msg="colorlogic light count"): + lights = get_equipment_by_type(msp, OmniType.CL_LIGHT) + assert len(lights) == 3 + # Pool has 2 lights, Spa has 1 + light_ids = sorted([light.system_id for light in lights]) + assert light_ids == [13, 23, 24] + + with subtests.test(msg="relay count"): + relays = get_equipment_by_type(msp, OmniType.RELAY) + assert len(relays) >= 3 + relay_names = [relay.name for relay in relays] + assert "Yard Lights" in relay_names + assert "Blower" in relay_names + + with subtests.test(msg="pump count"): + pumps = get_equipment_by_type(msp, OmniType.PUMP) + assert len(pumps) == 1 + assert pumps[0].system_id == 14 + assert pumps[0].name == "Jet" + + with subtests.test(msg="filter count"): + filters = get_equipment_by_type(msp, OmniType.FILTER) + # Should have 2 filters (one per BoW) + assert len(filters) == 2 + + with subtests.test(msg="heater equipment"): + heaters = get_equipment_by_type(msp, OmniType.HEATER_EQUIP) + # Should have 4 heater equipment (2 gas + 2 solar) + assert len(heaters) == 4 + heater_names = [h.name for h in heaters] + assert heater_names.count("Gas") == 2 + assert heater_names.count("Solar") == 2 + + def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test Telemetry parsing for issue-60.""" + telem = Telemetry.from_xml(fixture_data["telemetry"]) + + with subtests.test(msg="backyard telemetry"): + assert telem.backyard is not None + assert telem.backyard.system_id == 0 + assert telem.backyard.air_temp == 67 + + with subtests.test(msg="body of water telemetry count"): + assert len(telem.bow) == 2 + bow_ids = sorted([bow.system_id for bow in telem.bow]) + assert bow_ids == [1, 8] + + with subtests.test(msg="pool water temp"): + pool_bow = [bow for bow in telem.bow if bow.system_id == 1][0] + assert pool_bow.water_temp == 74 + + with subtests.test(msg="spa water temp"): + spa_bow = [bow for bow in telem.bow if bow.system_id == 8][0] + assert spa_bow.water_temp == -1 # No valid reading + + with subtests.test(msg="filter telemetry"): + assert telem.filter is not None + assert len(telem.filter) == 2 + # Pool filter running + pool_filter = [f for f in telem.filter if f.system_id == 3][0] + assert pool_filter.speed == 31 + assert pool_filter.power == 79 + + with subtests.test(msg="colorlogic light telemetry"): + assert telem.colorlogic_light is not None + assert len(telem.colorlogic_light) == 3 + light_ids = sorted([light.system_id for light in telem.colorlogic_light]) + assert light_ids == [13, 23, 24] + + with subtests.test(msg="relay telemetry"): + assert telem.relay is not None + assert len(telem.relay) == 2 + # Check yard lights relay is on + yard_relay = [r for r in telem.relay if r.system_id == 27][0] + assert yard_relay.state.value == 1 # ON + + with subtests.test(msg="pump telemetry"): + assert telem.pump is not None + assert len(telem.pump) == 1 + assert telem.pump[0].system_id == 14 + assert telem.pump[0].state.value == 0 # OFF + + with subtests.test(msg="heater telemetry"): + assert telem.heater is not None + assert len(telem.heater) == 4 + heater_ids = sorted([h.system_id for h in telem.heater]) + assert heater_ids == [5, 12, 18, 19] + + with subtests.test(msg="group telemetry"): + assert telem.group is not None + assert len(telem.group) == 2 + group_ids = sorted([g.system_id for g in telem.group]) + assert group_ids == [33, 40] + + +# Add a parametrized test to quickly check all fixtures can be parsed +FIXTURE_FILES = sorted([f.name for f in FIXTURES_DIR.glob("issue-*.json")]) + + +@pytest.mark.parametrize("fixture_file", FIXTURE_FILES) +def test_fixture_parses_without_error(fixture_file: str) -> None: + """Verify that all fixture files can be parsed without errors. + + This is a smoke test to ensure basic parsing works for all fixtures. + Detailed validation is done in fixture-specific test classes. + + Args: + fixture_file: Name of the fixture file to test + """ + data = load_fixture(fixture_file) + + # Parse MSPConfig + if "mspconfig" in data and data["mspconfig"]: + msp = MSPConfig.load_xml(data["mspconfig"]) + assert msp is not None + assert msp.backyard is not None + + # Parse Telemetry + if "telemetry" in data and data["telemetry"]: + telem = Telemetry.from_xml(data["telemetry"]) + assert telem is not None + assert telem.backyard is not None diff --git a/uv.lock b/uv.lock index 53916e9..4d694a9 100644 --- a/uv.lock +++ b/uv.lock @@ -380,6 +380,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, ] +[[package]] +name = "pydantic-xml" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "pydantic-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/64/cd62cbbba095d69fe601c8d6a1924988907eb5845b397a124fb49e0e5e44/pydantic_xml-2.18.0.tar.gz", hash = "sha256:e6838c9247de2ffe28127581de3cc360bc7176abe5fd87751dbafdd966416749", size = 26168, upload-time = "2025-10-10T20:12:43.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/cd/6a9174b5a432ef4f49e271418104b62a0da2881cc6dfc6b73dd20498931e/pydantic_xml-2.18.0-py3-none-any.whl", hash = "sha256:9b2412c8c84242223979e9274ade1d3566028cf6a9b1cdb6389384d2db5292c0", size = 42484, upload-time = "2025-10-10T20:12:42.258Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -470,6 +483,7 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "pydantic" }, + { name = "pydantic-xml" }, { name = "xmltodict" }, ] @@ -493,6 +507,7 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.0.0,<8.4.0" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, + { name = "pydantic-xml", specifier = ">=2.18.0" }, { name = "scapy", marker = "extra == 'cli'", specifier = ">=2.6.1,<3.0.0" }, { name = "xmltodict", specifier = ">=1.0.2,<2.0.0" }, ] From 7a37b1da7e2198692b2ac7d9d62dc7954f5637e3 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 22:44:49 -0600 Subject: [PATCH 51/61] feat: migrate filter diags and leadmessage to pydantic-xml --- pyomnilogic_local/api/protocol.py | 2 +- pyomnilogic_local/cli/pcap_utils.py | 3 +- .../models/filter_diagnostics.py | 91 ++++++++++++++----- pyomnilogic_local/models/leadmessage.py | 77 +++++++++++----- tests/test_fixtures.py | 12 +-- 5 files changed, 131 insertions(+), 54 deletions(-) diff --git a/pyomnilogic_local/api/protocol.py b/pyomnilogic_local/api/protocol.py index 5c7c43d..ae855c2 100644 --- a/pyomnilogic_local/api/protocol.py +++ b/pyomnilogic_local/api/protocol.py @@ -363,7 +363,7 @@ async def _receive_file(self) -> str: # If the response is too large, the controller will send a LeadMessage indicating how many follow-up messages will be sent if message.type is MessageType.MSP_LEADMESSAGE: try: - leadmsg = LeadMessage.model_validate(ET.fromstring(message.payload[:-1])) + leadmsg = LeadMessage.from_xml(message.payload[:-1]) except Exception as exc: raise OmniFragmentationException(f"Failed to parse LeadMessage: {exc}") from exc diff --git a/pyomnilogic_local/cli/pcap_utils.py b/pyomnilogic_local/cli/pcap_utils.py index d828949..d0b07c8 100644 --- a/pyomnilogic_local/cli/pcap_utils.py +++ b/pyomnilogic_local/cli/pcap_utils.py @@ -7,7 +7,6 @@ from __future__ import annotations -import xml.etree.ElementTree as ET import zlib from collections import defaultdict from typing import Any @@ -147,7 +146,7 @@ def process_pcap_messages(packets: Any) -> list[tuple[str, str, OmniLogicMessage # Check if we have all the blocks lead_msg = message_sequences[matching_seq][0] - lead_data = LeadMessage.model_validate(ET.fromstring(lead_msg.payload[:-1])) + lead_data = LeadMessage.from_xml(lead_msg.payload[:-1]) # We have LeadMessage + all BlockMessages if len(message_sequences[matching_seq]) == lead_data.msg_block_count + 1: diff --git a/pyomnilogic_local/models/filter_diagnostics.py b/pyomnilogic_local/models/filter_diagnostics.py index 9423531..dab558b 100644 --- a/pyomnilogic_local/models/filter_diagnostics.py +++ b/pyomnilogic_local/models/filter_diagnostics.py @@ -1,42 +1,87 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict, Field -from xmltodict import parse as xml_parse +from pydantic import ConfigDict +from pydantic_xml import BaseXmlModel, attr, element, wrapped +from .const import XML_NS -class FilterDiagnosticsParameter(BaseModel): - model_config = ConfigDict(from_attributes=True) +# Example Filter Diagnostics XML: +# +# +# +# GetUIFilterDiagnosticInfoRsp +# +# 7 +# 8 +# 133 +# 4 +# 0 +# 49 +# 48 +# 49 +# 53 +# 32 +# 0 +# 48 +# 48 +# 55 +# 48 +# 32 +# 0 +# +# - name: str = Field(alias="@name") - dataType: str = Field(alias="@dataType") - value: int = Field(alias="#text") +class FilterDiagnosticsParameter(BaseXmlModel, tag="Parameter", ns="api", nsmap=XML_NS): + """Individual diagnostic parameter with name, type, and value.""" -class FilterDiagnosticsParameters(BaseModel): model_config = ConfigDict(from_attributes=True) - parameter: list[FilterDiagnosticsParameter] = Field(alias="Parameter") + name: str = attr() + data_type: str = attr(name="dataType") + value: int + +class FilterDiagnostics(BaseXmlModel, tag="Response", ns="api", nsmap=XML_NS): + """Filter diagnostics response containing diagnostic parameters. + + The XML structure has a Parameters wrapper element containing Parameter children: + + FilterDiagnostics + + value + ... + + + """ -class FilterDiagnostics(BaseModel): model_config = ConfigDict(from_attributes=True) - name: str = Field(alias="Name") - # parameters: FilterDiagnosticsParameters = Field(alias="Parameters") - parameters: list[FilterDiagnosticsParameter] = Field(alias="Parameters") + name: str = element(tag="Name") + parameters: list[FilterDiagnosticsParameter] = wrapped("Parameters", element(tag="Parameter", default_factory=list)) def get_param_by_name(self, name: str) -> int: + """Get parameter value by name. + + Args: + name: Name of the parameter to retrieve + + Returns: + The integer value of the parameter + + Raises: + IndexError: If parameter name not found + """ return [param.value for param in self.parameters if param.name == name][0] @staticmethod def load_xml(xml: str) -> FilterDiagnostics: - data = xml_parse( - xml, - # Some things will be lists or not depending on if a pool has more than one of that piece of equipment. Here we are coercing - # everything that *could* be a list into a list to make the parsing more consistent. - force_list=("Parameter"), - ) - # The XML nests the Parameter entries under a Parameters entry, this is annoying to work with. Here we are adjusting the data to - # remove that extra level in the data - data["Response"]["Parameters"] = data["Response"]["Parameters"]["Parameter"] - return FilterDiagnostics.model_validate(data["Response"]) + """Load filter diagnostics from XML string. + + Args: + xml: XML string containing filter diagnostics data + + Returns: + Parsed FilterDiagnostics instance + """ + return FilterDiagnostics.from_xml(xml) diff --git a/pyomnilogic_local/models/leadmessage.py b/pyomnilogic_local/models/leadmessage.py index 1034de6..54727ae 100644 --- a/pyomnilogic_local/models/leadmessage.py +++ b/pyomnilogic_local/models/leadmessage.py @@ -1,30 +1,63 @@ from __future__ import annotations -from typing import Any -from xml.etree.ElementTree import Element - -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import ConfigDict, computed_field +from pydantic_xml import BaseXmlModel, attr, element, wrapped from .const import XML_NS +# Example Lead Message XML: +# +# +# +# LeadMessage +# +# 1003 +# 3709 +# 4 +# 0 +# +# + + +class LeadMessageParameter(BaseXmlModel, tag="Parameter", ns="api", nsmap=XML_NS): + """Individual parameter in lead message.""" + + name: str = attr() + value: int + + +class LeadMessage(BaseXmlModel, tag="Response", ns="api", nsmap=XML_NS): + """Lead message containing protocol parameters. + + Lead messages are sent at the start of communication to establish + protocol parameters like message size and block count. + """ -class LeadMessage(BaseModel): model_config = ConfigDict(from_attributes=True) - source_op_id: int = Field(alias="SourceOpId") - msg_size: int = Field(alias="MsgSize") - msg_block_count: int = Field(alias="MsgBlockCount") - type: int = Field(alias="Type") - - @model_validator(mode="before") - @classmethod - def parse_xml_element(cls, data: Any) -> dict[str, Any]: - """Parse XML Element into dict format for Pydantic validation.""" - if isinstance(data, Element): - # Parse the Parameter elements from the XML - result = {} - for param in data.findall(".//api:Parameter", XML_NS): - if name := param.get("name"): - result[name] = int(param.text) if param.text else 0 - return result - return data + name: str = element(tag="Name") + parameters: list[LeadMessageParameter] = wrapped("Parameters", element(tag="Parameter", default_factory=list)) + + @computed_field # type: ignore[prop-decorator] + @property + def source_op_id(self) -> int: + """Extract SourceOpId from parameters.""" + return next((p.value for p in self.parameters if p.name == "SourceOpId"), 0) + + @computed_field # type: ignore[prop-decorator] + @property + def msg_size(self) -> int: + """Extract MsgSize from parameters.""" + return next((p.value for p in self.parameters if p.name == "MsgSize"), 0) + + @computed_field # type: ignore[prop-decorator] + @property + def msg_block_count(self) -> int: + """Extract MsgBlockCount from parameters.""" + return next((p.value for p in self.parameters if p.name == "MsgBlockCount"), 0) + + @computed_field # type: ignore[prop-decorator] + @property + def type(self) -> int: + """Extract Type from parameters.""" + return next((p.value for p in self.parameters if p.name == "Type"), 0) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 7ca00f2..b376aba 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -109,7 +109,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No pool = msp.backyard.bow[0] assert pool.system_id == 3 assert pool.name == "Pool" - assert pool.omni_type == OmniType.BOW_MSP + assert pool.omni_type == OmniType.BOW with subtests.test(msg="filter configuration"): filters = get_equipment_by_type(msp, OmniType.FILTER) @@ -155,7 +155,7 @@ def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> No assert len(telem.virtual_heater) == 1 vh = telem.virtual_heater[0] assert vh.system_id == 15 - assert vh.current_set_point == 82 + assert vh.current_set_point == 65 with subtests.test(msg="heater equipment telemetry"): assert telem.heater is not None @@ -202,7 +202,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No pool = msp.backyard.bow[0] assert pool.system_id == 10 assert pool.name == "Pool" - assert pool.omni_type == OmniType.BOW_MSP + assert pool.omni_type == OmniType.BOW with subtests.test(msg="filter configuration"): filters = get_equipment_by_type(msp, OmniType.FILTER) @@ -225,7 +225,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No with subtests.test(msg="backyard sensors"): assert msp.backyard.sensor is not None assert len(msp.backyard.sensor) == 1 - assert msp.backyard.sensor[0].system_id == 1 + assert msp.backyard.sensor[0].system_id == 16 def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None: """Test Telemetry parsing for issue-163.""" @@ -299,7 +299,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No pool = msp.backyard.bow[0] assert pool.system_id == 1 assert pool.name == "Pool" - assert pool.omni_type == OmniType.BOW_MSP + assert pool.omni_type == OmniType.BOW with subtests.test(msg="spa configuration"): assert msp.backyard.bow is not None @@ -378,7 +378,7 @@ def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> No with subtests.test(msg="relay telemetry"): assert telem.relay is not None - assert len(telem.relay) == 2 + assert len(telem.relay) == 3 # Check yard lights relay is on yard_relay = [r for r in telem.relay if r.system_id == 27][0] assert yard_relay.state.value == 1 # ON From 303925256830c1973b3e8038d4a7c1253247823d Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 23:17:09 -0600 Subject: [PATCH 52/61] fix: go back to xmltodict for now, mspconfig migration is... complex... --- .../models/filter_diagnostics.py | 67 ++--- pyomnilogic_local/models/leadmessage.py | 64 ++--- pyomnilogic_local/models/telemetry.py | 249 ++++++++++-------- 3 files changed, 188 insertions(+), 192 deletions(-) diff --git a/pyomnilogic_local/models/filter_diagnostics.py b/pyomnilogic_local/models/filter_diagnostics.py index dab558b..3d4735e 100644 --- a/pyomnilogic_local/models/filter_diagnostics.py +++ b/pyomnilogic_local/models/filter_diagnostics.py @@ -1,9 +1,7 @@ from __future__ import annotations -from pydantic import ConfigDict -from pydantic_xml import BaseXmlModel, attr, element, wrapped - -from .const import XML_NS +from pydantic import BaseModel, ConfigDict, Field +from xmltodict import parse as xml_parse # Example Filter Diagnostics XML: # @@ -32,56 +30,39 @@ # -class FilterDiagnosticsParameter(BaseXmlModel, tag="Parameter", ns="api", nsmap=XML_NS): - """Individual diagnostic parameter with name, type, and value.""" - +class FilterDiagnosticsParameter(BaseModel): model_config = ConfigDict(from_attributes=True) - name: str = attr() - data_type: str = attr(name="dataType") - value: int - + name: str = Field(alias="@name") + dataType: str = Field(alias="@dataType") + value: int = Field(alias="#text") -class FilterDiagnostics(BaseXmlModel, tag="Response", ns="api", nsmap=XML_NS): - """Filter diagnostics response containing diagnostic parameters. - - The XML structure has a Parameters wrapper element containing Parameter children: - - FilterDiagnostics - - value - ... - - - """ +class FilterDiagnosticsParameters(BaseModel): model_config = ConfigDict(from_attributes=True) - name: str = element(tag="Name") - parameters: list[FilterDiagnosticsParameter] = wrapped("Parameters", element(tag="Parameter", default_factory=list)) + parameter: list[FilterDiagnosticsParameter] = Field(alias="Parameter") - def get_param_by_name(self, name: str) -> int: - """Get parameter value by name. - Args: - name: Name of the parameter to retrieve +class FilterDiagnostics(BaseModel): + model_config = ConfigDict(from_attributes=True) - Returns: - The integer value of the parameter + name: str = Field(alias="Name") + # parameters: FilterDiagnosticsParameters = Field(alias="Parameters") + parameters: list[FilterDiagnosticsParameter] = Field(alias="Parameters") - Raises: - IndexError: If parameter name not found - """ + def get_param_by_name(self, name: str) -> int: return [param.value for param in self.parameters if param.name == name][0] @staticmethod def load_xml(xml: str) -> FilterDiagnostics: - """Load filter diagnostics from XML string. - - Args: - xml: XML string containing filter diagnostics data - - Returns: - Parsed FilterDiagnostics instance - """ - return FilterDiagnostics.from_xml(xml) + data = xml_parse( + xml, + # Some things will be lists or not depending on if a pool has more than one of that piece of equipment. Here we are coercing + # everything that *could* be a list into a list to make the parsing more consistent. + force_list=("Parameter"), + ) + # The XML nests the Parameter entries under a Parameters entry, this is annoying to work with. Here we are adjusting the data to + # remove that extra level in the data + data["Response"]["Parameters"] = data["Response"]["Parameters"]["Parameter"] + return FilterDiagnostics.model_validate(data["Response"]) diff --git a/pyomnilogic_local/models/leadmessage.py b/pyomnilogic_local/models/leadmessage.py index 54727ae..c0a34ab 100644 --- a/pyomnilogic_local/models/leadmessage.py +++ b/pyomnilogic_local/models/leadmessage.py @@ -1,7 +1,9 @@ from __future__ import annotations -from pydantic import ConfigDict, computed_field -from pydantic_xml import BaseXmlModel, attr, element, wrapped +from typing import Any +from xml.etree.ElementTree import Element + +from pydantic import BaseModel, ConfigDict, Field, model_validator from .const import XML_NS @@ -19,45 +21,23 @@ # -class LeadMessageParameter(BaseXmlModel, tag="Parameter", ns="api", nsmap=XML_NS): - """Individual parameter in lead message.""" - - name: str = attr() - value: int - - -class LeadMessage(BaseXmlModel, tag="Response", ns="api", nsmap=XML_NS): - """Lead message containing protocol parameters. - - Lead messages are sent at the start of communication to establish - protocol parameters like message size and block count. - """ - +class LeadMessage(BaseModel): model_config = ConfigDict(from_attributes=True) - name: str = element(tag="Name") - parameters: list[LeadMessageParameter] = wrapped("Parameters", element(tag="Parameter", default_factory=list)) - - @computed_field # type: ignore[prop-decorator] - @property - def source_op_id(self) -> int: - """Extract SourceOpId from parameters.""" - return next((p.value for p in self.parameters if p.name == "SourceOpId"), 0) - - @computed_field # type: ignore[prop-decorator] - @property - def msg_size(self) -> int: - """Extract MsgSize from parameters.""" - return next((p.value for p in self.parameters if p.name == "MsgSize"), 0) - - @computed_field # type: ignore[prop-decorator] - @property - def msg_block_count(self) -> int: - """Extract MsgBlockCount from parameters.""" - return next((p.value for p in self.parameters if p.name == "MsgBlockCount"), 0) - - @computed_field # type: ignore[prop-decorator] - @property - def type(self) -> int: - """Extract Type from parameters.""" - return next((p.value for p in self.parameters if p.name == "Type"), 0) + source_op_id: int = Field(alias="SourceOpId") + msg_size: int = Field(alias="MsgSize") + msg_block_count: int = Field(alias="MsgBlockCount") + type: int = Field(alias="Type") + + @model_validator(mode="before") + @classmethod + def parse_xml_element(cls, data: Any) -> dict[str, Any]: + """Parse XML Element into dict format for Pydantic validation.""" + if isinstance(data, Element): + # Parse the Parameter elements from the XML + result = {} + for param in data.findall(".//api:Parameter", XML_NS): + if name := param.get("name"): + result[name] = int(param.text) if param.text else 0 + return result + return data diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index 34cef8a..af3c631 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import cast +from typing import Any, SupportsInt, cast, overload -from pydantic import ConfigDict, ValidationError -from pydantic_xml import BaseXmlModel, attr, element +from pydantic import BaseModel, ConfigDict, Field, ValidationError, computed_field +from xmltodict import parse as xml_parse from ..omnitypes import ( BackyardState, @@ -55,7 +55,7 @@ # -class TelemetryBackyard(BaseXmlModel, tag="Backyard"): +class TelemetryBackyard(BaseModel): """Real-time telemetry for the backyard/controller system. This is the top-level telemetry object containing system-wide state information. @@ -71,16 +71,16 @@ class TelemetryBackyard(BaseXmlModel, tag="Backyard"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.BACKYARD - system_id: int = attr(name="systemId") - status_version: int = attr(name="statusVersion") - air_temp: int | None = attr(name="airTemp") - state: BackyardState = attr() + system_id: int = Field(alias="@systemId") + status_version: int = Field(alias="@statusVersion") + air_temp: int | None = Field(alias="@airTemp") + state: BackyardState = Field(alias="@state") # The below two fields are only available for telemetry with a status_version >= 11 - config_checksum: int = attr(name="ConfigChksum", default=0) - msp_version: str | None = attr(name="mspVersion", default=None) + config_checksum: int = Field(alias="@ConfigChksum", default=0) + msp_version: str | None = Field(alias="@mspVersion", default=None) -class TelemetryBoW(BaseXmlModel, tag="BodyOfWater"): +class TelemetryBoW(BaseModel): """Real-time telemetry for a body of water (pool or spa). Contains current water conditions and flow status. @@ -93,12 +93,12 @@ class TelemetryBoW(BaseXmlModel, tag="BodyOfWater"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.BOW - system_id: int = attr(name="systemId") - water_temp: int = attr(name="waterTemp") - flow: int = attr() + system_id: int = Field(alias="@systemId") + water_temp: int = Field(alias="@waterTemp") + flow: int = Field(alias="@flow") -class TelemetryChlorinator(BaseXmlModel, tag="Chlorinator"): +class TelemetryChlorinator(BaseModel): """Real-time telemetry for salt chlorinator systems. Includes salt levels, operational status, alerts, and errors. Use computed @@ -118,18 +118,19 @@ class TelemetryChlorinator(BaseXmlModel, tag="Chlorinator"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CHLORINATOR - system_id: int = attr(name="systemId") - status_raw: int = attr(name="status") - instant_salt_level: int = attr(name="instantSaltLevel") - avg_salt_level: int = attr(name="avgSaltLevel") - chlr_alert_raw: int = attr(name="chlrAlert") - chlr_error_raw: int = attr(name="chlrError") - sc_mode: int = attr(name="scMode") - operating_state: int = attr(name="operatingState") - timed_percent: int | None = attr(name="Timed-Percent", default=None) - operating_mode: ChlorinatorOperatingMode = attr(name="operatingMode") - enable: bool = attr() - + system_id: int = Field(alias="@systemId") + status_raw: int = Field(alias="@status") + instant_salt_level: int = Field(alias="@instantSaltLevel") + avg_salt_level: int = Field(alias="@avgSaltLevel") + chlr_alert_raw: int = Field(alias="@chlrAlert") + chlr_error_raw: int = Field(alias="@chlrError") + sc_mode: int = Field(alias="@scMode") + operating_state: int = Field(alias="@operatingState") + timed_percent: int | None = Field(alias="@Timed-Percent", default=None) + operating_mode: ChlorinatorOperatingMode = Field(alias="@operatingMode") + enable: bool = Field(alias="@enable") + + @computed_field # type: ignore[prop-decorator] @property def status(self) -> list[str]: """Decode status bitmask into a list of active status flag names. @@ -143,6 +144,7 @@ def status(self) -> list[str]: """ return [flag.name for flag in ChlorinatorStatus if self.status_raw & flag.value and flag.name is not None] + @computed_field # type: ignore[prop-decorator] @property def alerts(self) -> list[str]: """Decode chlrAlert bitmask into a list of active alert flag names. @@ -173,6 +175,7 @@ def alerts(self) -> list[str]: return final_flags + @computed_field # type: ignore[prop-decorator] @property def errors(self) -> list[str]: """Decode chlrError bitmask into a list of active error flag names. @@ -203,6 +206,7 @@ def errors(self) -> list[str]: return final_flags + @computed_field # type: ignore[prop-decorator] @property def active(self) -> bool: """Check if the chlorinator is actively generating chlorine. @@ -213,7 +217,7 @@ def active(self) -> bool: return ChlorinatorStatus.GENERATING.value & self.status_raw == ChlorinatorStatus.GENERATING.value -class TelemetryCSAD(BaseXmlModel, tag="CSAD"): +class TelemetryCSAD(BaseModel): """Real-time telemetry for Chemistry Sense and Dispense systems. Provides current water chemistry readings and dispensing status. @@ -228,14 +232,14 @@ class TelemetryCSAD(BaseXmlModel, tag="CSAD"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CSAD - system_id: int = attr(name="systemId") - status: CSADStatus = attr() - ph: float = attr() - orp: int = attr() - mode: CSADMode = attr() + system_id: int = Field(alias="@systemId") + status: CSADStatus = Field(alias="@status") + ph: float = Field(alias="@ph") + orp: int = Field(alias="@orp") + mode: CSADMode = Field(alias="@mode") -class TelemetryColorLogicLight(BaseXmlModel, tag="ColorLogic-Light"): +class TelemetryColorLogicLight(BaseModel): """Real-time telemetry for ColorLogic LED lighting systems. Tracks power state, active show, speed, and brightness settings. Light cannot @@ -254,12 +258,12 @@ class TelemetryColorLogicLight(BaseXmlModel, tag="ColorLogic-Light"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CL_LIGHT - system_id: int = attr(name="systemId") - state: ColorLogicPowerState = attr(name="lightState") - show: LightShows = attr(name="currentShow") - speed: ColorLogicSpeed = attr() - brightness: ColorLogicBrightness = attr() - special_effect: int = attr(name="specialEffect") + system_id: int = Field(alias="@systemId") + state: ColorLogicPowerState = Field(alias="@lightState") + show: LightShows = Field(alias="@currentShow") + speed: ColorLogicSpeed = Field(alias="@speed") + brightness: ColorLogicBrightness = Field(alias="@brightness") + special_effect: int = Field(alias="@specialEffect") def show_name( self, model: ColorLogicLightType, v2: bool, pretty: bool = False @@ -286,7 +290,7 @@ def show_name( return self.show # Return raw int if type is unknown -class TelemetryFilter(BaseXmlModel, tag="Filter"): +class TelemetryFilter(BaseModel): """Real-time telemetry for filter pump systems. Includes operational state, speed settings, and valve position. Filter cannot @@ -305,17 +309,17 @@ class TelemetryFilter(BaseXmlModel, tag="Filter"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.FILTER - system_id: int = attr(name="systemId") - state: FilterState = attr(name="filterState") - speed: int = attr(name="filterSpeed") - valve_position: FilterValvePosition = attr(name="valvePosition") - why_on: FilterWhyOn = attr(name="whyFilterIsOn") - reported_speed: int = attr(name="reportedFilterSpeed") - power: int = attr() - last_speed: int = attr(name="lastSpeed") + system_id: int = Field(alias="@systemId") + state: FilterState = Field(alias="@filterState") + speed: int = Field(alias="@filterSpeed") + valve_position: FilterValvePosition = Field(alias="@valvePosition") + why_on: FilterWhyOn = Field(alias="@whyFilterIsOn") + reported_speed: int = Field(alias="@reportedFilterSpeed") + power: int = Field(alias="@power") + last_speed: int = Field(alias="@lastSpeed") -class TelemetryGroup(BaseXmlModel, tag="Group"): +class TelemetryGroup(BaseModel): """Real-time telemetry for equipment groups. Groups allow controlling multiple pieces of equipment together as a single unit. @@ -327,11 +331,11 @@ class TelemetryGroup(BaseXmlModel, tag="Group"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.GROUP - system_id: int = attr(name="systemId") - state: GroupState = attr(name="groupState") + system_id: int = Field(alias="@systemId") + state: GroupState = Field(alias="@groupState") -class TelemetryHeater(BaseXmlModel, tag="Heater"): +class TelemetryHeater(BaseModel): """Real-time telemetry for physical heater equipment. Represents actual heater hardware (gas, heat pump, solar, etc.) controlled @@ -348,15 +352,15 @@ class TelemetryHeater(BaseXmlModel, tag="Heater"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.HEATER - system_id: int = attr(name="systemId") - state: HeaterState = attr(name="heaterState") - temp: int = attr() - enabled: bool = attr(name="enable") - priority: int = attr() - maintain_for: int = attr(name="maintainFor") + system_id: int = Field(alias="@systemId") + state: HeaterState = Field(alias="@heaterState") + temp: int = Field(alias="@temp") + enabled: bool = Field(alias="@enable") + priority: int = Field(alias="@priority") + maintain_for: int = Field(alias="@maintainFor") -class TelemetryPump(BaseXmlModel, tag="Pump"): +class TelemetryPump(BaseModel): """Real-time telemetry for auxiliary pump equipment. Auxiliary pumps are separate from filter pumps and used for water features, @@ -372,14 +376,14 @@ class TelemetryPump(BaseXmlModel, tag="Pump"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.PUMP - system_id: int = attr(name="systemId") - state: PumpState = attr(name="pumpState") - speed: int = attr(name="pumpSpeed") - last_speed: int = attr(name="lastSpeed") - why_on: int = attr(name="whyOn") + system_id: int = Field(alias="@systemId") + state: PumpState = Field(alias="@pumpState") + speed: int = Field(alias="@pumpSpeed") + last_speed: int = Field(alias="@lastSpeed") + why_on: int = Field(alias="@whyOn") -class TelemetryRelay(BaseXmlModel, tag="Relay"): +class TelemetryRelay(BaseModel): """Real-time telemetry for relay-controlled equipment. Relays provide simple on/off control for lights, water features, and other @@ -393,12 +397,12 @@ class TelemetryRelay(BaseXmlModel, tag="Relay"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.RELAY - system_id: int = attr(name="systemId") - state: RelayState = attr(name="relayState") - why_on: RelayWhyOn = attr(name="whyOn") + system_id: int = Field(alias="@systemId") + state: RelayState = Field(alias="@relayState") + why_on: RelayWhyOn = Field(alias="@whyOn") -class TelemetryValveActuator(BaseXmlModel, tag="ValveActuator"): +class TelemetryValveActuator(BaseModel): """Real-time telemetry for valve actuator equipment. Valve actuators control motorized valves for directing water flow. Functionally @@ -412,13 +416,13 @@ class TelemetryValveActuator(BaseXmlModel, tag="ValveActuator"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.VALVE_ACTUATOR - system_id: int = attr(name="systemId") - state: ValveActuatorState = attr(name="valveActuatorState") + system_id: int = Field(alias="@systemId") + state: ValveActuatorState = Field(alias="@valveActuatorState") # Valve actuators are actually relays, so we can reuse the RelayWhyOn enum here - why_on: RelayWhyOn = attr(name="whyOn") + why_on: RelayWhyOn = Field(alias="@whyOn") -class TelemetryVirtualHeater(BaseXmlModel, tag="VirtualHeater"): +class TelemetryVirtualHeater(BaseModel): """Real-time telemetry for virtual heater controller. Virtual heater acts as the control logic for one or more physical heaters, @@ -436,13 +440,13 @@ class TelemetryVirtualHeater(BaseXmlModel, tag="VirtualHeater"): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.VIRT_HEATER - system_id: int = attr(name="systemId") - current_set_point: int = attr(name="Current-Set-Point") - enabled: bool = attr(name="enable") - solar_set_point: int = attr(name="SolarSetPoint") - mode: HeaterMode = attr(name="Mode") - silent_mode: int = attr(name="SilentMode") - why_on: int = attr(name="whyHeaterIsOn") + system_id: int = Field(alias="@systemId") + current_set_point: int = Field(alias="@Current-Set-Point") + enabled: bool = Field(alias="@enable") + solar_set_point: int = Field(alias="@SolarSetPoint") + mode: HeaterMode = Field(alias="@Mode") + silent_mode: int = Field(alias="@SilentMode") + why_on: int = Field(alias="@whyHeaterIsOn") type TelemetryType = ( @@ -461,7 +465,7 @@ class TelemetryVirtualHeater(BaseXmlModel, tag="VirtualHeater"): ) -class Telemetry(BaseXmlModel, tag="STATUS", search_mode="unordered"): +class Telemetry(BaseModel): """Complete real-time telemetry snapshot from the OmniLogic controller. Contains the current state of all equipment in the system. Telemetry is requested @@ -488,35 +492,66 @@ class Telemetry(BaseXmlModel, tag="STATUS", search_mode="unordered"): model_config = ConfigDict(from_attributes=True) - version: str = attr() - backyard: TelemetryBackyard = element() - bow: list[TelemetryBoW] = element(tag="BodyOfWater", default=[]) - chlorinator: list[TelemetryChlorinator] | None = element(tag="Chlorinator", default=None) - colorlogic_light: list[TelemetryColorLogicLight] | None = element(tag="ColorLogic-Light", default=None) - csad: list[TelemetryCSAD] | None = element(tag="CSAD", default=None) - filter: list[TelemetryFilter] | None = element(tag="Filter", default=None) - group: list[TelemetryGroup] | None = element(tag="Group", default=None) - heater: list[TelemetryHeater] | None = element(tag="Heater", default=None) - pump: list[TelemetryPump] | None = element(tag="Pump", default=None) - relay: list[TelemetryRelay] | None = element(tag="Relay", default=None) - valve_actuator: list[TelemetryValveActuator] | None = element(tag="ValveActuator", default=None) - virtual_heater: list[TelemetryVirtualHeater] | None = element(tag="VirtualHeater", default=None) + version: str = Field(alias="@version") + backyard: TelemetryBackyard = Field(alias="Backyard") + bow: list[TelemetryBoW] = Field(alias="BodyOfWater") + chlorinator: list[TelemetryChlorinator] | None = Field(alias="Chlorinator", default=None) + colorlogic_light: list[TelemetryColorLogicLight] | None = Field(alias="ColorLogic-Light", default=None) + csad: list[TelemetryCSAD] | None = Field(alias="CSAD", default=None) + filter: list[TelemetryFilter] | None = Field(alias="Filter", default=None) + group: list[TelemetryGroup] | None = Field(alias="Group", default=None) + heater: list[TelemetryHeater] | None = Field(alias="Heater", default=None) + pump: list[TelemetryPump] | None = Field(alias="Pump", default=None) + relay: list[TelemetryRelay] | None = Field(alias="Relay", default=None) + valve_actuator: list[TelemetryValveActuator] | None = Field(alias="ValveActuator", default=None) + virtual_heater: list[TelemetryVirtualHeater] | None = Field(alias="VirtualHeater", default=None) @staticmethod def load_xml(xml: str) -> Telemetry: - """Load telemetry from XML string. - - Args: - xml: XML string containing telemetry data - - Returns: - Parsed Telemetry object - - Raises: - OmniParsingException: If XML parsing or validation fails - """ + @overload + def xml_postprocessor(path: Any, key: Any, value: SupportsInt) -> tuple[Any, SupportsInt]: ... + @overload + def xml_postprocessor(path: Any, key: Any, value: Any) -> tuple[Any, Any]: ... + def xml_postprocessor(path: Any, key: Any, value: SupportsInt | Any) -> tuple[Any, SupportsInt | Any]: + """Post process XML to attempt to convert values to int. + + Pydantic can coerce values natively, but the Omni API returns values as strings of numbers (I.E. "2", "5", etc) and we need them + coerced into int enums. Pydantic only seems to be able to handle one coercion, so it could coerce an int into an Enum, but it + cannot coerce a string into an int and then into the Enum. We help it out a little bit here by preemptively coercing any + string ints into an int, then pydantic handles the int to enum coercion if necessary. + """ + newvalue: SupportsInt | Any + + try: + newvalue = int(value) + except (ValueError, TypeError): + newvalue = value + + return key, newvalue + + data = xml_parse( + xml, + postprocessor=xml_postprocessor, + # Some things will be lists or not depending on if a pool has more than one of that piece of equipment. Here we are coercing + # everything into lists to make the parsing more consistent. This does mean that some things that would normally never be lists + # will become lists (I.E.: Backyard, VirtualHeater), but the upside is that we need far less conditional code to deal with the + # "maybe list maybe not" devices. + force_list=( + OmniType.BOW, + OmniType.CHLORINATOR, + OmniType.CSAD, + OmniType.CL_LIGHT, + OmniType.FILTER, + OmniType.GROUP, + OmniType.HEATER, + OmniType.PUMP, + OmniType.RELAY, + OmniType.VALVE_ACTUATOR, + OmniType.VIRT_HEATER, + ), + ) try: - return Telemetry.from_xml(xml) + return Telemetry.model_validate(data["STATUS"]) except ValidationError as exc: raise OmniParsingException(f"Failed to parse Telemetry: {exc}") from exc From 16ce86c2e0301c9e8b9aed66b60984c4f53754a2 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Wed, 5 Nov 2025 23:26:39 -0600 Subject: [PATCH 53/61] fix: rollback more of the pydantic-xml changes --- pyomnilogic_local/api/protocol.py | 2 +- pyomnilogic_local/cli/pcap_utils.py | 3 +- pyproject.toml | 1 - tests/test_chlorinator_bitmask.py | 122 ++++++++++----------- tests/test_chlorinator_multibit.py | 160 ++++++++++++++-------------- tests/test_filter_pump.py | 26 ++--- tests/test_fixtures.py | 8 +- uv.lock | 15 --- 8 files changed, 161 insertions(+), 176 deletions(-) diff --git a/pyomnilogic_local/api/protocol.py b/pyomnilogic_local/api/protocol.py index ae855c2..5c7c43d 100644 --- a/pyomnilogic_local/api/protocol.py +++ b/pyomnilogic_local/api/protocol.py @@ -363,7 +363,7 @@ async def _receive_file(self) -> str: # If the response is too large, the controller will send a LeadMessage indicating how many follow-up messages will be sent if message.type is MessageType.MSP_LEADMESSAGE: try: - leadmsg = LeadMessage.from_xml(message.payload[:-1]) + leadmsg = LeadMessage.model_validate(ET.fromstring(message.payload[:-1])) except Exception as exc: raise OmniFragmentationException(f"Failed to parse LeadMessage: {exc}") from exc diff --git a/pyomnilogic_local/cli/pcap_utils.py b/pyomnilogic_local/cli/pcap_utils.py index d0b07c8..d828949 100644 --- a/pyomnilogic_local/cli/pcap_utils.py +++ b/pyomnilogic_local/cli/pcap_utils.py @@ -7,6 +7,7 @@ from __future__ import annotations +import xml.etree.ElementTree as ET import zlib from collections import defaultdict from typing import Any @@ -146,7 +147,7 @@ def process_pcap_messages(packets: Any) -> list[tuple[str, str, OmniLogicMessage # Check if we have all the blocks lead_msg = message_sequences[matching_seq][0] - lead_data = LeadMessage.from_xml(lead_msg.payload[:-1]) + lead_data = LeadMessage.model_validate(ET.fromstring(lead_msg.payload[:-1])) # We have LeadMessage + all BlockMessages if len(message_sequences[matching_seq]) == lead_data.msg_block_count + 1: diff --git a/pyproject.toml b/pyproject.toml index 5817281..5fa2ad2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ dependencies = [ "pydantic >=2.0.0,<3.0.0", "click >=8.0.0,<8.4.0", "xmltodict >=1.0.2,<2.0.0", - "pydantic-xml>=2.18.0", ] [project.scripts] diff --git a/tests/test_chlorinator_bitmask.py b/tests/test_chlorinator_bitmask.py index 74f4dad..7bf4425 100644 --- a/tests/test_chlorinator_bitmask.py +++ b/tests/test_chlorinator_bitmask.py @@ -13,17 +13,17 @@ def test_chlorinator_status_decoding() -> None: # Bit 7: K2_ACTIVE (128) # Total: 2 + 4 + 128 = 134 data = { - "system_id": 5, - "status_raw": 134, - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0, - "chlr_error_raw": 0, - "sc_mode": 0, - "operating_state": 1, - "timed_percent": 70, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 134, + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0, + "@chlrError": 0, + "@scMode": 0, + "@operatingState": 1, + "@Timed-Percent": 70, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -47,16 +47,16 @@ def test_chlorinator_alert_decoding() -> None: # Create a chlorinator with chlrAlert = 32 (0b00100000) # Bit 5: CELL_TEMP_SCALEBACK (32) data = { - "system_id": 5, - "status_raw": 2, # ALERT_PRESENT - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 32, - "chlr_error_raw": 0, - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 2, # ALERT_PRESENT + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 32, + "@chlrError": 0, + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -77,16 +77,16 @@ def test_chlorinator_error_decoding() -> None: # Bit 8: K1_RELAY_SHORT (256) # Total: 1 + 256 = 257 data = { - "system_id": 5, - "status_raw": 1, # ERROR_PRESENT - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0, - "chlr_error_raw": 257, - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 1, # ERROR_PRESENT + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0, + "@chlrError": 257, + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -104,16 +104,16 @@ def test_chlorinator_error_decoding() -> None: def test_chlorinator_no_flags() -> None: """Test chlorinator with no status/alert/error flags set.""" data = { - "system_id": 5, - "status_raw": 0, - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0, - "chlr_error_raw": 0, - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 0, + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0, + "@chlrError": 0, + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -133,16 +133,16 @@ def test_chlorinator_complex_alerts() -> None: # Bit 6: BOARD_TEMP_HIGH (64) # Total: 1 + 2 + 64 = 67 data = { - "system_id": 5, - "status_raw": 2, - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 67, - "chlr_error_raw": 0, - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 2, + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 67, + "@chlrError": 0, + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -158,16 +158,16 @@ def test_chlorinator_all_status_flags() -> None: """Test chlorinator with all status flags set.""" # status = 255 (0b11111111) - all 8 bits set data = { - "system_id": 5, - "status_raw": 255, - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0, - "chlr_error_raw": 0, - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 255, + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0, + "@chlrError": 0, + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) diff --git a/tests/test_chlorinator_multibit.py b/tests/test_chlorinator_multibit.py index 74ef19e..81d2453 100644 --- a/tests/test_chlorinator_multibit.py +++ b/tests/test_chlorinator_multibit.py @@ -10,16 +10,16 @@ def test_cell_temp_high_special_case() -> None: # Cell Water Temp bits 5:4 = 11 (both CELL_TEMP_LOW and CELL_TEMP_SCALEBACK set) # This should be replaced with "CELL_TEMP_HIGH" data = { - "system_id": 5, - "status_raw": 2, # ALERT_PRESENT - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0b11_0000, # bits 5:4 = 11 - "chlr_error_raw": 0, - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 2, # ALERT_PRESENT + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0b11_0000, # bits 5:4 = 11 + "@chlrError": 0, + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -34,16 +34,16 @@ def test_cell_temp_high_special_case() -> None: def test_cell_temp_low_only() -> None: """Test that CELL_TEMP_LOW appears normally when only that bit is set.""" data = { - "system_id": 5, - "status_raw": 2, - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0b01_0000, # bits 5:4 = 01 - "chlr_error_raw": 0, - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 2, + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0b01_0000, # bits 5:4 = 01 + "@chlrError": 0, + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -56,16 +56,16 @@ def test_cell_temp_low_only() -> None: def test_cell_temp_scaleback_only() -> None: """Test that CELL_TEMP_SCALEBACK appears normally when only that bit is set.""" data = { - "system_id": 5, - "status_raw": 2, - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0b10_0000, # bits 5:4 = 10 - "chlr_error_raw": 0, - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 2, + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0b10_0000, # bits 5:4 = 10 + "@chlrError": 0, + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -80,16 +80,16 @@ def test_cell_comm_loss_special_case() -> None: # Cell Error bits 13:12 = 11 (both CELL_ERROR_TYPE and CELL_ERROR_AUTH set) # This should be replaced with "CELL_COMM_LOSS" data = { - "system_id": 5, - "status_raw": 1, # ERROR_PRESENT - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0, - "chlr_error_raw": 0b11_000000000000, # bits 13:12 = 11 - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 1, # ERROR_PRESENT + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0, + "@chlrError": 0b11_000000000000, # bits 13:12 = 11 + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -104,16 +104,16 @@ def test_cell_comm_loss_special_case() -> None: def test_cell_error_type_only() -> None: """Test that CELL_ERROR_TYPE appears normally when only that bit is set.""" data = { - "system_id": 5, - "status_raw": 1, - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0, - "chlr_error_raw": 0b01_000000000000, # bits 13:12 = 01 - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 1, + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0, + "@chlrError": 0b01_000000000000, # bits 13:12 = 01 + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -126,16 +126,16 @@ def test_cell_error_type_only() -> None: def test_cell_error_auth_only() -> None: """Test that CELL_ERROR_AUTH appears normally when only that bit is set.""" data = { - "system_id": 5, - "status_raw": 1, - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0, - "chlr_error_raw": 0b10_000000000000, # bits 13:12 = 10 - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 1, + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0, + "@chlrError": 0b10_000000000000, # bits 13:12 = 10 + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -149,16 +149,16 @@ def test_combined_with_other_flags() -> None: """Test special cases work correctly when combined with other flags.""" # Multiple alerts including CELL_TEMP_HIGH data = { - "system_id": 5, - "status_raw": 2, - "instant_salt_level": 3000, - "avg_salt_level": 3000, - "chlr_alert_raw": 0x31, # SALT_LOW + CELL_TEMP_HIGH (0b00110001) - "chlr_error_raw": 0, - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 2, + "@instantSaltLevel": 3000, + "@avgSaltLevel": 3000, + "@chlrAlert": 0x31, # SALT_LOW + CELL_TEMP_HIGH (0b00110001) + "@chlrError": 0, + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) @@ -170,16 +170,16 @@ def test_combined_with_other_flags() -> None: # Multiple errors including CELL_COMM_LOSS data = { - "system_id": 5, - "status_raw": 1, - "instant_salt_level": 4082, - "avg_salt_level": 4042, - "chlr_alert_raw": 0, - "chlr_error_raw": 0x3001, # CURRENT_SENSOR_SHORT + CELL_COMM_LOSS - "sc_mode": 0, - "operating_state": 1, - "operating_mode": 1, - "enable": True, + "@systemId": 5, + "@status": 1, + "@instantSaltLevel": 4082, + "@avgSaltLevel": 4042, + "@chlrAlert": 0, + "@chlrError": 0x3001, # CURRENT_SENSOR_SHORT + CELL_COMM_LOSS + "@scMode": 0, + "@operatingState": 1, + "@operatingMode": 1, + "@enable": True, } chlorinator = TelemetryChlorinator.model_validate(data) diff --git a/tests/test_filter_pump.py b/tests/test_filter_pump.py index 31042dd..230dd25 100644 --- a/tests/test_filter_pump.py +++ b/tests/test_filter_pump.py @@ -55,14 +55,14 @@ def sample_filter_telemetry(): return TelemetryFilter( omni_type=OmniType.FILTER, **{ - "system_id": 8, - "state": 1, - "speed": 60, - "valve_position": 1, - "why_on": 14, - "reported_speed": 60, - "power": 500, - "last_speed": 50, + "@systemId": 8, + "@filterState": 1, + "@filterSpeed": 60, + "@valvePosition": 1, + "@whyFilterIsOn": 14, + "@reportedFilterSpeed": 60, + "@power": 500, + "@lastSpeed": 50, }, ) @@ -94,11 +94,11 @@ def sample_pump_telemetry(): return TelemetryPump( omni_type=OmniType.PUMP, **{ - "system_id": 15, - "state": 1, - "speed": 60, - "last_speed": 50, - "why_on": 11, + "@systemId": 15, + "@pumpState": 1, + "@pumpSpeed": 60, + "@lastSpeed": 50, + "@whyOn": 11, }, ) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index b376aba..6462771 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -131,7 +131,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None: """Test Telemetry parsing for issue-144.""" - telem = Telemetry.from_xml(fixture_data["telemetry"]) + telem = Telemetry.load_xml(fixture_data["telemetry"]) with subtests.test(msg="backyard telemetry"): assert telem.backyard is not None @@ -229,7 +229,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None: """Test Telemetry parsing for issue-163.""" - telem = Telemetry.from_xml(fixture_data["telemetry"]) + telem = Telemetry.load_xml(fixture_data["telemetry"]) with subtests.test(msg="backyard telemetry"): assert telem.backyard is not None @@ -342,7 +342,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None: """Test Telemetry parsing for issue-60.""" - telem = Telemetry.from_xml(fixture_data["telemetry"]) + telem = Telemetry.load_xml(fixture_data["telemetry"]) with subtests.test(msg="backyard telemetry"): assert telem.backyard is not None @@ -426,6 +426,6 @@ def test_fixture_parses_without_error(fixture_file: str) -> None: # Parse Telemetry if "telemetry" in data and data["telemetry"]: - telem = Telemetry.from_xml(data["telemetry"]) + telem = Telemetry.load_xml(data["telemetry"]) assert telem is not None assert telem.backyard is not None diff --git a/uv.lock b/uv.lock index 4d694a9..53916e9 100644 --- a/uv.lock +++ b/uv.lock @@ -380,19 +380,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, ] -[[package]] -name = "pydantic-xml" -version = "2.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "pydantic-core" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/64/cd62cbbba095d69fe601c8d6a1924988907eb5845b397a124fb49e0e5e44/pydantic_xml-2.18.0.tar.gz", hash = "sha256:e6838c9247de2ffe28127581de3cc360bc7176abe5fd87751dbafdd966416749", size = 26168, upload-time = "2025-10-10T20:12:43.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/cd/6a9174b5a432ef4f49e271418104b62a0da2881cc6dfc6b73dd20498931e/pydantic_xml-2.18.0-py3-none-any.whl", hash = "sha256:9b2412c8c84242223979e9274ade1d3566028cf6a9b1cdb6389384d2db5292c0", size = 42484, upload-time = "2025-10-10T20:12:42.258Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -483,7 +470,6 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "pydantic" }, - { name = "pydantic-xml" }, { name = "xmltodict" }, ] @@ -507,7 +493,6 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.0.0,<8.4.0" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, - { name = "pydantic-xml", specifier = ">=2.18.0" }, { name = "scapy", marker = "extra == 'cli'", specifier = ">=2.6.1,<3.0.0" }, { name = "xmltodict", specifier = ">=1.0.2,<2.0.0" }, ] From 75c16e6a640216b5c770cd767b4f4cb9a84e7ea3 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Thu, 6 Nov 2025 00:01:54 -0600 Subject: [PATCH 54/61] doc: update README.md --- README.md | 367 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 341 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 4bb6fc3..c292f73 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,354 @@ -# Pyomnilogic Local +
-

- - PyPI Version - - Supported Python versions - License - Buy Me A Coffee -

+# Python OmniLogic Local -A library implementing the UDP XML Local Control api for Hayward OmniLogic and OmniHub pool controllers +[![PyPI Version](https://img.shields.io/pypi/v/python-omnilogic-local.svg?logo=python&logoColor=fff&style=flat-square)](https://pypi.org/project/python-omnilogic-local/) +[![Python Versions](https://img.shields.io/pypi/pyversions/python-omnilogic-local.svg?style=flat-square&logo=python&logoColor=fff)](https://pypi.org/project/python-omnilogic-local/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/cryptk/python-omnilogic-local/build-test.yml?style=flat-square&label=Build)](https://github.com/cryptk/python-omnilogic-local/actions) +[![License](https://img.shields.io/pypi/l/python-omnilogic-local.svg?style=flat-square)](LICENSE) +[![Buy Me A Coffee](https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=000)](https://www.buymeacoffee.com/cryptk) + +**A modern Python library for local control of Hayward OmniLogic and OmniHub pool controllers** + +[Features](#features) • [Installation](#installation) • [Quick Start](#quick-start) • [Documentation](#documentation) • [CLI Tool](#cli-tool) + +
+ +--- + +## Overview + +Python OmniLogic Local provides complete local control over Hayward OmniLogic and OmniHub pool automation systems using their UDP-based XML protocol. Built with modern Python 3.12+, comprehensive type hints, and Pydantic validation, this library offers a async, type-safe interface for pool automation. + +## Features + +### Equipment Control +- **Heaters**: Temperature control, mode selection (heat/auto/off), solar support +- **Pumps & Filters**: Variable speed control, on/off operation, diagnostic information +- **ColorLogic Lights**: Multiple models supported (2.5, 4.0, UCL, SAM), brightness, speed, show selection +- **Relays**: Control auxiliary equipment like fountains, deck jets, blowers +- **Chlorinators**: Timed percent control, enable/disable operation +- **Groups**: Coordinated equipment control (turn multiple devices on/off together) +- **Schedules**: Enable/disable automated schedules + +### Monitoring & State Management +- **Real-time Telemetry**: Water temperature, chemical readings, equipment state +- **Configuration Discovery**: Automatic detection of all equipment and capabilities +- **Sensor Data**: pH, ORP, TDS, salt levels, flow sensors +- **Filter Diagnostics**: Last speed, valve positions, priming states +- **Equipment Hierarchy**: Automatic parent-child relationship tracking + +### Developer-Friendly Design +- **Type Safety**: Comprehensive type hints with strict mypy validation +- **Async/Await**: Non-blocking asyncio-based API +- **Pydantic Models**: Automatic validation and serialization +- **Smart State Management**: Automatic dirty tracking and efficient refreshing +- **Equipment Collections**: Dict-like and attribute access patterns +- **Generic Architecture**: Type-safe equipment hierarchy with generics ## Installation -This package is published to pypi at https://pypi.org/project/python-omnilogic-local/: +**Requirements**: Python 3.12 or higher + +```bash +pip install python-omnilogic-local +``` + +**With CLI tools** (includes packet capture utilities): +```bash +pip install python-omnilogic-local[cli] +``` + +## Quick Start + +### Basic Usage + +```python +import asyncio +from pyomnilogic_local import OmniLogic + +async def main(): + # Connect to your OmniLogic controller + omni = OmniLogic("192.168.1.100") + + # Initial refresh to load configuration and state + await omni.refresh() + + # Access equipment by name + pool = omni.backyard.bow["Pool"] + + # Control heater + heater = pool.heater + print(f"Current temperature: {heater.current_temperature}°F") + print(f"Target temperature: {heater.current_set_point}°F") + + await heater.set_temperature(85) + await heater.turn_on() + + # Refresh to get updated state + await omni.refresh() + + # Control lights + from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicSpeed + + light = pool.lights["Pool Light"] + await light.turn_on() + await light.set_show( + show=light.effects.TWILIGHT, + brightness=ColorLogicBrightness.ONE_HUNDRED_PERCENT, + speed=ColorLogicSpeed.ONE_TIMES + ) + + # Control pump speed + pump = pool.pumps["Pool Pump"] + await pump.set_speed(75) # Set to 75% + +asyncio.run(main()) +``` + +### Monitoring Equipment State + +```python +async def monitor_pool(): + omni = OmniLogic("192.168.1.100") + await omni.refresh() + + pool = omni.backyard.bow["Pool"] + + # Check multiple equipment states + print(f"Water temperature: {pool.heater.current_temperature}°F") + print(f"Heater is {'on' if pool.heater.is_on else 'off'}") + print(f"Pump speed: {pool.pumps['Main Pump'].current_speed}%") + + # Check all lights + for name, light in pool.lights.items(): + if light.is_on: + print(f"{name}: {light.show.name} @ {light.brightness.name}") + else: + print(f"{name}: OFF") + + # Access chemical sensors + if pool.sensors: + for name, sensor in pool.sensors.items(): + print(f"{name}: {sensor.current_reading}") + +asyncio.run(monitor_pool()) +``` + +### Efficient State Updates + +The library includes intelligent state management to minimize unnecessary API calls: + +```python +# Force immediate refresh +await omni.refresh(force=True) + +# Refresh only if data is older than 30 seconds +await omni.refresh(if_older_than=30.0) + +# Refresh only if equipment state changed (default after control commands) +await omni.refresh(if_dirty=True) +``` + +## Documentation + +### Equipment Hierarchy + +``` +OmniLogic +├── Backyard +│ ├── Bodies of Water (BOW) +│ │ ├── Heater (single virtual heater) +│ │ ├── Pumps +│ │ ├── Filters +│ │ ├── Chlorinator +│ │ ├── Lights (ColorLogic) +│ │ ├── Relays +│ │ ├── Sensors +│ │ └── CSAD (Chemical Sensing & Dispensing) +│ ├── Lights (ColorLogic) +│ ├── Relays +│ └── Sensors +├── Groups +└── Schedules +``` + +### Accessing Equipment + +Equipment can be accessed using dictionary-style or attribute-style syntax: -`pip install python-omnilogic-local` +```python +# Dictionary access (by name) +pool = omni.backyard.bow["Pool"] -## Functionality +# Heater is a single object (not a collection) +heater = pool.heater -This library is still under development and is not yet able to control every function of a Hayward pool controller. The implemented functionality is: +# Most equipment are collections +for pump_name, pump in pool.pumps.items(): + print(f"Pump: {pump_name} - Speed: {pump.current_speed}%") -- Pulling the MSP Config -- Polling telemetry -- Polling a list of active alarms -- Polling filter/pump diagnostic information -- Polling the logging configuration -- Setting pool heater temperature -- Turning pool heaters on/off -- Turning other pool equipment on/off, including countdown timers -- Setting filter/pump speed -- Controlling ColorLogic lights including brightness, speed, and selected shows, with support for countdown timers +# Lights, relays, and sensors can be on both BOW and backyard levels +for light_name, light in pool.lights.items(): + print(f"BOW Light: {light_name}") -If your controller has functionality outside of this list, please do not hesitate to [Open an Issue](https://github.com/cryptk/python-omnilogic-local/issues) +for light_name, light in omni.backyard.lights.items(): + print(f"Backyard Light: {light_name}") + +# Groups and schedules are at the OmniLogic level +for group_name, group in omni.groups.items(): + print(f"Group: {group_name}") +``` + +### Equipment Properties + +All equipment exposes standard properties: + +```python +equipment.name # Equipment name +equipment.system_id # Unique system identifier +equipment.bow_id # Body of water ID (if applicable) +equipment.is_ready # Whether equipment can accept commands +equipment.mspconfig # Configuration data +equipment.telemetry # Real-time state data +``` + +### Control Methods + +Control methods are async and automatically handle readiness checks: + +```python +from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicSpeed + +# All control methods are async +await heater.turn_on() +await heater.turn_off() +await heater.set_temperature(85) + +# Light show control - brightness and speed are parameters to set_show() +await light.set_show( + show=light.effects.CARIBBEAN, + brightness=ColorLogicBrightness.EIGHTY_PERCENT, + speed=ColorLogicSpeed.TWO_TIMES +) + +# Pump speed control +await pump.set_speed(75) + +# State is automatically marked dirty after control commands +# Refresh to get updated telemetry +await omni.refresh() +``` + +### Exception Handling + +The library provides specific exception types: + +```python +from pyomnilogic_local import ( + OmniLogicLocalError, # Base exception + OmniEquipmentNotReadyError, # Equipment in transitional state + OmniEquipmentNotInitializedError, # Missing required attributes + OmniConnectionError, # Network/communication errors +) + +try: + await heater.set_temperature(120) # Too high +except OmniValidationException as e: + print(f"Invalid temperature: {e}") + +try: + await light.turn_on() +except OmniEquipmentNotReadyError as e: + print(f"Light not ready: {e}") +``` + +## CLI Tool + +The library includes a command-line tool for monitoring and debugging: + +```bash +# Get telemetry data +omnilogic --host 192.168.1.100 debug get-telemetry + +# List all equipment +omnilogic get lights +omnilogic get pumps +omnilogic get heaters + +# Get raw XML responses +omnilogic debug --raw get-mspconfig + +# View filter diagnostics +omnilogic debug get-filter-diagnostics +``` + +**Installation with CLI tools**: +```bash +pip install python-omnilogic-local[cli] +``` + +## Supported Equipment + +### Fully Supported +- Pool/Spa Heaters (gas, heat pump, solar, hybrid) +- Variable Speed Pumps & Filters +- ColorLogic Lights (2.5, 4.0, UCL, SAM models) +- Relays (water features, auxiliary equipment) +- Chlorinators (timed percent control) +- Sensors (temperature, pH, ORP, TDS, salt, flow) +- Groups (coordinated equipment control) +- Schedules (enable/disable) +- CSAD (Chemical Sensing & Dispensing) - monitoring + +### Partial Support +- CSAD equipment control (monitoring only currently) +- Some advanced heater configurations + +> [!NOTE] +> If your controller has equipment not listed here, please [open an issue](https://github.com/cryptk/python-omnilogic-local/issues) with details about your configuration. + +## Development + +This project uses modern Python tooling: + +- **Python**: 3.12+ with type hints +- **Type Checking**: mypy strict mode +- **Validation**: Pydantic v2 +- **Testing**: pytest with async support +- **Code Quality**: black, isort, pylint, ruff +- **Package Management**: uv (optional) or pip + +### Running Tests + +```bash +# Install development dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Run with coverage +pytest --cov=pyomnilogic_local --cov-report=html + +# Type checking +mypy pyomnilogic_local + +# Linting +pylint pyomnilogic_local +``` ## Credits -The work on this library would not have been possible without the efforts of [djtimca](https://github.com/djtimca/) and [John Sutherland](garionphx@gmail.com) +This library was made possible by the pioneering work of: + +- [djtimca](https://github.com/djtimca/) - Original protocol research and implementation +- [John Sutherland](mailto:garionphx@gmail.com) - Protocol documentation and testing + +## Related Projects + +- [Home Assistant Integration](https://github.com/cryptk/haomnilogic-local) - Use this library with Home Assistant + +## Disclaimer + +This is an unofficial library and is not affiliated with, endorsed by, or connected to Hayward Industries, Inc. Use at your own risk. The developers are not responsible for any damage to equipment or property resulting from the use of this software. From 11667f84118f2159b3e5fcc425a9af3835237787 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 8 Nov 2025 10:39:34 -0600 Subject: [PATCH 55/61] feat: add Dockerfile for running the CLI --- .dockerignore | 54 +++++++++++ .gitignore | 3 + DOCKER.md | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 54 +++++++++++ 4 files changed, 374 insertions(+) create mode 100644 .dockerignore create mode 100644 DOCKER.md create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1179c54 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ +.tox/ +.mypy_cache/ +.ruff_cache/ +tests/ + +# Git +.git/ +.gitignore + +# CI/CD +.github/ + +# Documentation +docs/ +*.md +!README.md + +# Development files +dev_files/ +tests/ + +# Misc +.DS_Store +Thumbs.db +dev_files/ diff --git a/.gitignore b/.gitignore index eeb1b41..d1f891d 100644 --- a/.gitignore +++ b/.gitignore @@ -140,6 +140,9 @@ venv.bak/ .dmypy.json dmypy.json +# ruff +.ruff_cache/ + # Pyre type checker .pyre/ diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..fe8a9c0 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,263 @@ +# Docker Usage Guide + +This guide explains how to build and use the Docker image for the `omnilogic` CLI tool. + +## Quick Start + +### Build the Image + +```bash +docker build -t omnilogic-cli . +``` + +### Run the CLI + +Replace `192.168.1.100` with your OmniLogic controller's IP address: + +```bash +# Show help +docker run --rm omnilogic-cli --help + +# Get raw MSP configuration +docker run --rm omnilogic-cli --host 192.168.1.100 debug --raw get-mspconfig + +# Get telemetry data parsed with pydantic +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-telemetry + +# List lights +docker run --rm omnilogic-cli --host 192.168.1.100 get lights + +# List pumps +docker run --rm omnilogic-cli --host 192.168.1.100 get pumps + +``` + +## CLI Structure + +The CLI has two main command groups: + +### `get` - Query Equipment Information + +Retrieves information about specific pool equipment. + +```bash +# View available equipment types +docker run --rm omnilogic-cli get --help + +# Examples +docker run --rm omnilogic-cli --host 192.168.1.100 get lights +docker run --rm omnilogic-cli --host 192.168.1.100 get heaters +docker run --rm omnilogic-cli --host 192.168.1.100 get pumps +docker run --rm omnilogic-cli --host 192.168.1.100 get chlorinators +docker run --rm omnilogic-cli --host 192.168.1.100 get schedules +docker run --rm omnilogic-cli --host 192.168.1.100 get sensors +``` + +### `debug` - Low-level Controller Access + +Provides direct access to controller data and debugging utilities. + +```bash +# View debug commands +docker run --rm omnilogic-cli debug --help + +# Get configuration (use --raw for unprocessed XML) +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-mspconfig +docker run --rm omnilogic-cli --host 192.168.1.100 debug --raw get-mspconfig + +# Get telemetry (use --raw for unprocessed XML) +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-telemetry +docker run --rm omnilogic-cli --host 192.168.1.100 debug --raw get-telemetry + +# Get filter diagnostics (requires pool-id and filter-id) +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-filter-diagnostics --pool-id 1 --filter-id 5 + +# Control equipment directly (BOW_ID EQUIP_ID IS_ON) +docker run --rm omnilogic-cli --host 192.168.1.100 debug set-equipment 7 10 true +docker run --rm omnilogic-cli --host 192.168.1.100 debug set-equipment 7 8 50 +``` + +## Network Considerations + +The container needs to reach your OmniLogic controller on UDP port 10444. Ensure: + +1. Your Docker network can reach the controller's IP +2. No firewall is blocking UDP port 10444 +3. The default bridge networking should work fine +4. For host networking (if needed): + +```bash +docker run --rm --network host omnilogic-cli --host 192.168.1.100 get lights +``` + +## Advanced Usage + +### Save Output to File + +```bash +# Redirect output to a file on the host +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-telemetry > telemetry.xml + +# Using volume mounts +docker run --rm -v $(pwd):/data omnilogic-cli --host 192.168.1.100 debug get-mspconfig > /data/config.xml +``` + +### Interactive Shell + +Run multiple commands without rebuilding the connection: + +```bash +docker run --rm -it --entrypoint /bin/bash omnilogic-cli + +# Inside container: +omnilogic --host 192.168.1.100 get lights +omnilogic --host 192.168.1.100 get pumps +omnilogic --host 192.168.1.100 debug get-telemetry +``` + +### Parse PCAP Files + +The CLI includes a PCAP parser for analyzing OmniLogic protocol traffic. Mount the PCAP file into the container: + +```bash +# Capture traffic with tcpdump (on your host or network device) +tcpdump -i eth0 -w pool.pcap udp port 10444 + +# Parse the PCAP file with Docker +docker run --rm -v $(pwd):/data omnilogic-cli debug parse-pcap /data/pool.pcap +``` + +**Note**: The `parse-pcap` command analyzes existing PCAP files; it does NOT capture live traffic. Use tcpdump, Wireshark, or similar tools to create the PCAP file first. + +## Docker Compose + +Create a `docker-compose.yml` file for easier usage: + +```yaml +version: '3.8' + +services: + omnilogic: + build: . + image: omnilogic-cli + volumes: + - ./captures:/data # For PCAP file analysis +``` + +Run commands with: + +```bash +# Query equipment +docker-compose run --rm omnilogic --host 192.168.1.100 get lights + +# Debug commands +docker-compose run --rm omnilogic --host 192.168.1.100 debug get-telemetry + +# Parse PCAP files from ./captures directory +docker-compose run --rm omnilogic debug parse-pcap /data/pool.pcap +``` + +## Building for Multiple Architectures + +Build for both AMD64 and ARM64 (useful for Raspberry Pi): + +```bash +docker buildx build --platform linux/amd64,linux/arm64 -t omnilogic-cli . +``` + +## Image Details + +### Size + +The multi-stage build keeps the image size minimal: +- Builder stage: ~500MB (discarded after build) +- Final runtime image: ~150-200MB + +### Included Dependencies + +- Python 3.12 +- Core dependencies: pydantic, click, xmltodict +- CLI dependencies: scapy (for PCAP parsing) +- Runtime tools: tcpdump (for potential traffic capture outside container) + +## Security Notes + +- The container runs as a non-root user (`omnilogic`, UID 1000) for security +- No sensitive data is stored in the image +- Network access is only required to communicate with your OmniLogic controller on UDP port 10444 +- PCAP parsing does NOT require elevated privileges (only parsing existing files) + +## Troubleshooting + +### Cannot reach controller + +```bash +# Test basic connectivity +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-mspconfig +``` + +If this fails, check: +- Controller IP address is correct and reachable +- Docker container can access your network +- No firewall blocking UDP port 10444 +- Controller is powered on and responsive + +### Connection timeout + +The default timeout is 5 seconds. If your network is slow: +- Check network latency to the controller +- Ensure UDP port 10444 is not being filtered +- Try from host networking mode: `--network host` + +### PCAP file not found + +When parsing PCAP files, ensure the file path is accessible from inside the container: + +```bash +# BAD - file not accessible to container +docker run --rm omnilogic-cli debug parse-pcap /home/user/pool.pcap + +# GOOD - mount the directory containing the PCAP +docker run --rm -v /home/user:/data omnilogic-cli debug parse-pcap /data/pool.pcap +``` + +## Command Reference + +### Equipment Query Commands + +```bash +# Get information about specific equipment types +docker run --rm omnilogic-cli --host get backyard # Backyard info +docker run --rm omnilogic-cli --host get bows # Bodies of water +docker run --rm omnilogic-cli --host get chlorinators # Chlorinators +docker run --rm omnilogic-cli --host get csads # Chemical systems +docker run --rm omnilogic-cli --host get filters # Filters/pumps +docker run --rm omnilogic-cli --host get groups # Equipment groups +docker run --rm omnilogic-cli --host get heaters # Heaters +docker run --rm omnilogic-cli --host get lights # Lights +docker run --rm omnilogic-cli --host get pumps # Pumps +docker run --rm omnilogic-cli --host get relays # Relays +docker run --rm omnilogic-cli --host get schedules # Schedules +docker run --rm omnilogic-cli --host get sensors # Sensors +docker run --rm omnilogic-cli --host get valves # Valves +``` + +### Debug Commands + +```bash +# Configuration and telemetry +docker run --rm omnilogic-cli --host debug get-mspconfig +docker run --rm omnilogic-cli --host debug get-telemetry +docker run --rm omnilogic-cli --host debug --raw get-mspconfig # Raw XML + +# Filter diagnostics (requires IDs from get-mspconfig) +docker run --rm omnilogic-cli --host debug get-filter-diagnostics --pool-id 1 --filter-id 5 + +# Equipment control (BOW_ID EQUIP_ID VALUE) +docker run --rm omnilogic-cli --host debug set-equipment 7 10 true # Turn on +docker run --rm omnilogic-cli --host debug set-equipment 7 10 false # Turn off +docker run --rm omnilogic-cli --host debug set-equipment 7 8 50 # 50% speed + +# PCAP analysis (file must be mounted into container) +docker run --rm -v $(pwd):/data omnilogic-cli debug parse-pcap /data/capture.pcap +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..03b0065 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# Multi-stage build for python-omnilogic-local CLI +# Stage 1: Builder +FROM python:3.12-slim AS builder + +# Set working directory +WORKDIR /build + +# Install build dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy project files +COPY pyproject.toml README.md LICENSE ./ +COPY pyomnilogic_local/ ./pyomnilogic_local/ + +# Install the package with CLI dependencies in a virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -e ".[cli]" + +# Stage 2: Runtime +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Install runtime dependencies for scapy (needed for CLI packet capture tools) +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + tcpdump \ + && rm -rf /var/lib/apt/lists/* + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv +COPY --from=builder /build /build + +# Set environment variables +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 + +# Create non-root user for security +RUN useradd -m -u 1000 omnilogic && \ + chown -R omnilogic:omnilogic /app + +USER omnilogic + +# Set entrypoint to the omnilogic CLI +ENTRYPOINT ["omnilogic"] + +# Default help command +CMD ["--help"] From 800d76419c6e4b6c9c480a911cc65d7cfb93ade9 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 8 Nov 2025 10:40:09 -0600 Subject: [PATCH 56/61] doc: cleanup DOCKER.md --- DOCKER.md | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index fe8a9c0..60f2159 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -84,37 +84,9 @@ The container needs to reach your OmniLogic controller on UDP port 10444. Ensure 1. Your Docker network can reach the controller's IP 2. No firewall is blocking UDP port 10444 3. The default bridge networking should work fine -4. For host networking (if needed): - -```bash -docker run --rm --network host omnilogic-cli --host 192.168.1.100 get lights -``` ## Advanced Usage -### Save Output to File - -```bash -# Redirect output to a file on the host -docker run --rm omnilogic-cli --host 192.168.1.100 debug get-telemetry > telemetry.xml - -# Using volume mounts -docker run --rm -v $(pwd):/data omnilogic-cli --host 192.168.1.100 debug get-mspconfig > /data/config.xml -``` - -### Interactive Shell - -Run multiple commands without rebuilding the connection: - -```bash -docker run --rm -it --entrypoint /bin/bash omnilogic-cli - -# Inside container: -omnilogic --host 192.168.1.100 get lights -omnilogic --host 192.168.1.100 get pumps -omnilogic --host 192.168.1.100 debug get-telemetry -``` - ### Parse PCAP Files The CLI includes a PCAP parser for analyzing OmniLogic protocol traffic. Mount the PCAP file into the container: From 7f1084a7a9dcd8f0ed8c1e0d1bb23eb7e22440a9 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 8 Nov 2025 16:56:21 -0600 Subject: [PATCH 57/61] chore: migrate from isort/black/pylint to ruff --- .pre-commit-config.yaml | 41 +- .vscode/extensions.json | 4 +- .vscode/settings.json | 2 +- pyomnilogic_local/__init__.py | 8 +- pyomnilogic_local/_base.py | 37 +- pyomnilogic_local/api/__init__.py | 7 + pyomnilogic_local/api/api.py | 56 ++- pyomnilogic_local/api/exceptions.py | 16 +- pyomnilogic_local/api/protocol.py | 151 +++--- pyomnilogic_local/backyard.py | 3 +- pyomnilogic_local/bow.py | 5 +- pyomnilogic_local/chlorinator.py | 25 +- pyomnilogic_local/chlorinator_equip.py | 4 +- pyomnilogic_local/cli/debug/__init__.py | 1 + pyomnilogic_local/cli/debug/commands.py | 21 +- pyomnilogic_local/cli/get/__init__.py | 1 + pyomnilogic_local/cli/get/backyard.py | 23 +- pyomnilogic_local/cli/get/bows.py | 18 +- pyomnilogic_local/cli/get/chlorinators.py | 22 +- pyomnilogic_local/cli/get/csads.py | 21 +- pyomnilogic_local/cli/get/filters.py | 21 +- pyomnilogic_local/cli/get/groups.py | 20 +- pyomnilogic_local/cli/get/heaters.py | 20 +- pyomnilogic_local/cli/get/lights.py | 26 +- pyomnilogic_local/cli/get/pumps.py | 20 +- pyomnilogic_local/cli/get/relays.py | 21 +- pyomnilogic_local/cli/get/schedules.py | 9 +- pyomnilogic_local/cli/get/sensors.py | 14 +- pyomnilogic_local/cli/get/valves.py | 20 +- pyomnilogic_local/cli/pcap_utils.py | 23 +- pyomnilogic_local/cli/utils.py | 18 +- pyomnilogic_local/collections.py | 45 +- pyomnilogic_local/colorlogiclight.py | 38 +- pyomnilogic_local/csad.py | 10 +- pyomnilogic_local/csad_equip.py | 4 +- pyomnilogic_local/decorators.py | 22 +- pyomnilogic_local/filter.py | 4 +- pyomnilogic_local/groups.py | 15 +- pyomnilogic_local/heater.py | 49 +- pyomnilogic_local/heater_equip.py | 9 +- pyomnilogic_local/models/__init__.py | 4 +- pyomnilogic_local/models/exceptions.py | 2 +- .../models/filter_diagnostics.py | 5 +- pyomnilogic_local/models/mspconfig.py | 49 +- pyomnilogic_local/models/telemetry.py | 19 +- pyomnilogic_local/omnilogic.py | 75 ++- pyomnilogic_local/omnitypes.py | 1 + pyomnilogic_local/pump.py | 4 +- pyomnilogic_local/relay.py | 18 +- pyomnilogic_local/schedule.py | 14 +- pyomnilogic_local/sensor.py | 10 +- pyomnilogic_local/system.py | 7 +- pyomnilogic_local/util.py | 7 +- pyproject.toml | 150 ++---- tests/__init__.py | 1 + tests/test_api.py | 89 ++-- tests/test_chlorinator_bitmask.py | 10 +- tests/test_chlorinator_multibit.py | 2 +- tests/test_decorators.py | 8 +- tests/test_effects_collection.py | 45 +- tests/test_filter_pump.py | 10 +- tests/test_fixtures.py | 37 +- tests/test_protocol.py | 82 ++-- uv.lock | 462 +----------------- 64 files changed, 695 insertions(+), 1290 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c43947..0fcbc9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.9.5 + rev: 0.9.8 hooks: - id: uv-lock - repo: https://github.com/codespell-project/codespell @@ -27,28 +27,31 @@ repos: hooks: - id: codespell exclude: uv.lock - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.14.1 + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version + rev: v0.14.4 hooks: + # Run the linter - id: ruff-check args: [ --fix ] + # Run the formatter - id: ruff-format - - repo: https://github.com/PyCQA/isort - rev: 7.0.0 - hooks: - - id: isort - - repo: https://github.com/psf/black - rev: 25.9.0 - hooks: - - id: black - - repo: local - hooks: - - id: pylint - name: pylint - entry: uv run pylint - language: system - types: [python] - require_serial: true + # - repo: https://github.com/PyCQA/isort # Replacing isort with ruff + # rev: 7.0.0 + # hooks: + # - id: isort + # - repo: https://github.com/psf/black # Replacing black with ruff + # rev: 25.9.0 + # hooks: + # - id: black + # - repo: local # Replacing pylint with ruff + # hooks: + # - id: pylint + # name: pylint + # entry: uv run pylint + # language: system + # types: [python] + # require_serial: true - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.18.2 hooks: diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 599bb10..dcf63f8 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,8 @@ { "recommendations": [ "ms-python.python", - "ms-python.black-formatter", "ms-python.mypy-type-checker", - "njpwerner.autodocstring" + "njpwerner.autodocstring", + "charliermarsh.ruff" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 164a6ef..fd02a96 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,7 @@ "mypy-type-checker.importStrategy": "fromEnvironment", "python.analysis.typeCheckingMode": "basic", "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter", + "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true } } diff --git a/pyomnilogic_local/__init__.py b/pyomnilogic_local/__init__.py index 29443a6..c9f842c 100644 --- a/pyomnilogic_local/__init__.py +++ b/pyomnilogic_local/__init__.py @@ -1,3 +1,5 @@ +"""PyOmniLogic-Local: A Python library for interacting with Hayward OmniLogic Local API.""" + from __future__ import annotations from .collections import EffectsCollection, LightEffectsCollection @@ -12,9 +14,9 @@ __all__ = [ "EffectsCollection", "LightEffectsCollection", + "OmniConnectionError", + "OmniEquipmentNotInitializedError", + "OmniEquipmentNotReadyError", "OmniLogic", "OmniLogicLocalError", - "OmniEquipmentNotReadyError", - "OmniEquipmentNotInitializedError", - "OmniConnectionError", ] diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py index ebf7b06..8b7898c 100644 --- a/pyomnilogic_local/_base.py +++ b/pyomnilogic_local/_base.py @@ -1,25 +1,22 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Generic, TypeVar, cast +from typing import TYPE_CHECKING, cast -from pyomnilogic_local.api.api import OmniLogicAPI -from pyomnilogic_local.models import MSPEquipmentType, Telemetry +from pyomnilogic_local.models import MSPEquipmentType from pyomnilogic_local.models.telemetry import TelemetryType from pyomnilogic_local.omnitypes import BackyardState if TYPE_CHECKING: + from pyomnilogic_local.api.api import OmniLogicAPI + from pyomnilogic_local.models import Telemetry from pyomnilogic_local.omnilogic import OmniLogic -# Define type variables for generic equipment types -MSPConfigT = TypeVar("MSPConfigT", bound=MSPEquipmentType) -TelemetryT = TypeVar("TelemetryT", bound=TelemetryType | None) - _LOGGER = logging.getLogger(__name__) -class OmniEquipment(Generic[MSPConfigT, TelemetryT]): +class OmniEquipment[MSPConfigT: MSPEquipmentType, TelemetryT: TelemetryType | None]: """Base class for all OmniLogic equipment. This is an abstract base class that provides common functionality for all equipment @@ -77,7 +74,7 @@ def __init__(self, omni: OmniLogic, mspconfig: MSPConfigT, telemetry: Telemetry @property def _api(self) -> OmniLogicAPI: """Access the OmniLogic API through the parent controller.""" - return self._omni._api # pylint: disable=protected-access + return self._omni._api @property def bow_id(self) -> int | None: @@ -113,13 +110,11 @@ def is_ready(self) -> bool: """ # Check if backyard state allows equipment operations backyard_state = self._omni.backyard.telemetry.state - if backyard_state in ( + return backyard_state not in ( BackyardState.SERVICE_MODE, BackyardState.CONFIG_MODE, BackyardState.TIMED_SERVICE_MODE, - ): - return False - return True + ) def update(self, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: """Update both the configuration and telemetry data for the equipment.""" @@ -130,14 +125,17 @@ def update(self, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: self._update_equipment(mspconfig, telemetry) def _update_equipment(self, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: - """Hook to allow classes to trigger updates of sub-equipment.""" + """Allow a class to trigger updates of sub-equipment. + + This method can be overridden by subclasses to update any child equipment. + """ def update_config(self, mspconfig: MSPConfigT) -> None: """Update the configuration data for the equipment.""" try: # If the Equipment has subdevices, we don't store those as part of this device's config # They will get parsed and stored as their own equipment instances - self.mspconfig = cast(MSPConfigT, mspconfig.without_subdevices()) + self.mspconfig = cast("MSPConfigT", mspconfig.without_subdevices()) except AttributeError: self.mspconfig = mspconfig @@ -148,9 +146,9 @@ def update_telemetry(self, telemetry: Telemetry) -> None: # Extract the specific telemetry for this equipment from the full telemetry object # Note: Some equipment (like sensors) don't have their own telemetry, so this may be None if (specific_telemetry := telemetry.get_telem_by_systemid(self.mspconfig.system_id)) is not None: - self.telemetry = cast(TelemetryT, specific_telemetry) + self.telemetry = cast("TelemetryT", specific_telemetry) else: - self.telemetry = cast(TelemetryT, None) + self.telemetry = cast("TelemetryT", None) def __repr__(self) -> str: """Return a string representation of the equipment for debugging. @@ -162,8 +160,7 @@ def __repr__(self) -> str: parts = [f"system_id={self.system_id!r}", f"name={self.name!r}"] # Include state if the equipment has telemetry with a state attribute - if hasattr(self, "telemetry") and self.telemetry is not None: - if (state := getattr(self.telemetry, "state", None)) is not None: - parts.append(f"state={state!r}") + if (hasattr(self, "telemetry") and self.telemetry is not None) and ((state := getattr(self.telemetry, "state", None)) is not None): + parts.append(f"state={state!r}") return f"{class_name}({', '.join(parts)})" diff --git a/pyomnilogic_local/api/__init__.py b/pyomnilogic_local/api/__init__.py index 3214c4f..b9f9ff1 100644 --- a/pyomnilogic_local/api/__init__.py +++ b/pyomnilogic_local/api/__init__.py @@ -1,3 +1,10 @@ +"""API module for interacting with Hayward OmniLogic pool controllers. + +This module provides the OmniLogicAPI class, which allows for local +control and monitoring of Hayward OmniLogic and OmniHub pool controllers +over a local network connection via the UDP XML API. +""" + from __future__ import annotations from .api import OmniLogicAPI diff --git a/pyomnilogic_local/api/api.py b/pyomnilogic_local/api/api.py index afa0a88..cf986c7 100644 --- a/pyomnilogic_local/api/api.py +++ b/pyomnilogic_local/api/api.py @@ -1,22 +1,19 @@ -# pylint: disable=too-many-positional-arguments from __future__ import annotations import asyncio import logging import xml.etree.ElementTree as ET -from typing import Literal, overload +from typing import TYPE_CHECKING, Literal, overload from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics from pyomnilogic_local.models.mspconfig import MSPConfig from pyomnilogic_local.models.telemetry import Telemetry - -from ..omnitypes import ( +from pyomnilogic_local.omnitypes import ( ColorLogicBrightness, ColorLogicSpeed, - HeaterMode, - LightShows, MessageType, ) + from .constants import ( DEFAULT_CONTROLLER_PORT, DEFAULT_RESPONSE_TIMEOUT, @@ -27,9 +24,12 @@ XML_ENCODING, XML_NAMESPACE, ) -from .exceptions import OmniValidationException +from .exceptions import OmniValidationError from .protocol import OmniLogicProtocol +if TYPE_CHECKING: + from pyomnilogic_local.omnitypes import HeaterMode, LightShows + _LOGGER = logging.getLogger(__name__) @@ -44,9 +44,11 @@ def _validate_temperature(temperature: int, param_name: str = "temperature") -> OmniValidationException: If temperature is out of range. """ if not isinstance(temperature, int): - raise OmniValidationException(f"{param_name} must be an integer, got {type(temperature).__name__}") + msg = f"{param_name} must be an integer, got {type(temperature).__name__}" + raise OmniValidationError(msg) if not MIN_TEMPERATURE_F <= temperature <= MAX_TEMPERATURE_F: - raise OmniValidationException(f"{param_name} must be between {MIN_TEMPERATURE_F}°F and {MAX_TEMPERATURE_F}°F, got {temperature}°F") + msg = f"{param_name} must be between {MIN_TEMPERATURE_F}°F and {MAX_TEMPERATURE_F}°F, got {temperature}°F" + raise OmniValidationError(msg) def _validate_speed(speed: int, param_name: str = "speed") -> None: @@ -60,9 +62,11 @@ def _validate_speed(speed: int, param_name: str = "speed") -> None: OmniValidationException: If speed is out of range. """ if not isinstance(speed, int): - raise OmniValidationException(f"{param_name} must be an integer, got {type(speed).__name__}") + msg = f"{param_name} must be an integer, got {type(speed).__name__}" + raise OmniValidationError(msg) if not MIN_SPEED_PERCENT <= speed <= MAX_SPEED_PERCENT: - raise OmniValidationException(f"{param_name} must be between {MIN_SPEED_PERCENT} and {MAX_SPEED_PERCENT}, got {speed}") + msg = f"{param_name} must be between {MIN_SPEED_PERCENT} and {MAX_SPEED_PERCENT}, got {speed}" + raise OmniValidationError(msg) def _validate_id(id_value: int, param_name: str) -> None: @@ -76,9 +80,11 @@ def _validate_id(id_value: int, param_name: str) -> None: OmniValidationException: If ID is invalid. """ if not isinstance(id_value, int): - raise OmniValidationException(f"{param_name} must be an integer, got {type(id_value).__name__}") + msg = f"{param_name} must be an integer, got {type(id_value).__name__}" + raise OmniValidationError(msg) if id_value < 0: - raise OmniValidationException(f"{param_name} must be non-negative, got {id_value}") + msg = f"{param_name} must be non-negative, got {id_value}" + raise OmniValidationError(msg) class OmniLogicAPI: @@ -96,11 +102,14 @@ def __init__( OmniValidationException: If parameters are invalid. """ if not controller_ip: - raise OmniValidationException("controller_ip cannot be empty") + msg = "controller_ip cannot be empty" + raise OmniValidationError(msg) if not isinstance(controller_port, int) or controller_port <= 0 or controller_port > 65535: - raise OmniValidationException(f"controller_port must be between 1 and 65535, got {controller_port}") + msg = f"controller_port must be between 1 and 65535, got {controller_port}" + raise OmniValidationError(msg) if not isinstance(response_timeout, (int, float)) or response_timeout <= 0: - raise OmniValidationException(f"response_timeout must be positive, got {response_timeout}") + msg = f"response_timeout must be positive, got {response_timeout}" + raise OmniValidationError(msg) self.controller_ip = controller_ip self.controller_port = controller_port @@ -179,6 +188,7 @@ async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, ra Args: pool_id (int): The Pool/BodyOfWater ID that you want to address equipment_id (int): Which equipment_id within that Pool to address + raw (bool): Do not parse the response into a Pydantic model, just return the raw XML. Defaults to False. Returns: FilterDiagnostics|str: Either a parsed .models.mspconfig.FilterDiagnostics object or a str depending on arg raw @@ -233,7 +243,7 @@ async def async_set_heater( equipment_id: int, temperature: int, ) -> None: - """Set the temperature for a heater on the Omni + """Set the temperature for a heater on the Omni. Args: pool_id (int): The Pool/BodyOfWater ID that you want to address @@ -332,7 +342,7 @@ async def async_set_heater_enable( equipment_id: int, enabled: int | bool, ) -> None: - """async_set_heater_enable handles sending a SetHeaterEnable XML API call to the Hayward Omni pool controller + """Send a SetHeaterEnable XML API call to the Hayward Omni pool controller. Args: pool_id (int): The Pool/BodyOfWater ID that you want to address @@ -381,11 +391,11 @@ async def async_set_equipment( For Variable Speed Pumps, you can optionally provide an int from 0-100 to set the speed percentage with 0 being Off. The interpretation of value depends on the piece of equipment being targeted. is_countdown_timer (bool, optional): For potential future use, included to be "API complete". Defaults to False. - startTimeHours (int, optional): For potential future use, included to be "API complete". Defaults to 0. - startTimeMinutes (int, optional): For potential future use, included to be "API complete". Defaults to 0. - endTimeHours (int, optional): For potential future use, included to be "API complete". Defaults to 0. - endTimeMinutes (int, optional): For potential future use, included to be "API complete". Defaults to 0. - daysActive (int, optional): For potential future use, included to be "API complete". Defaults to 0. + start_time_hours (int, optional): For potential future use, included to be "API complete". Defaults to 0. + start_time_minutes (int, optional): For potential future use, included to be "API complete". Defaults to 0. + end_time_hours (int, optional): For potential future use, included to be "API complete". Defaults to 0. + end_time_minutes (int, optional): For potential future use, included to be "API complete". Defaults to 0. + days_active (int, optional): For potential future use, included to be "API complete". Defaults to 0. recurring (bool, optional): For potential future use, included to be "API complete". Defaults to False. """ body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) diff --git a/pyomnilogic_local/api/exceptions.py b/pyomnilogic_local/api/exceptions.py index f6d18dc..5b3b026 100644 --- a/pyomnilogic_local/api/exceptions.py +++ b/pyomnilogic_local/api/exceptions.py @@ -1,33 +1,33 @@ from __future__ import annotations -class OmniLogicException(Exception): +class OmniLogicError(Exception): """Base exception for all OmniLogic errors.""" -class OmniProtocolException(OmniLogicException): +class OmniProtocolError(OmniLogicError): """Protocol-level errors during communication with the OmniLogic controller.""" -class OmniTimeoutException(OmniProtocolException): +class OmniTimeoutError(OmniProtocolError): """Timeout occurred while waiting for a response from the controller.""" -class OmniMessageFormatException(OmniProtocolException): +class OmniMessageFormatError(OmniProtocolError): """Received a malformed or invalid message from the controller.""" -class OmniFragmentationException(OmniProtocolException): +class OmniFragmentationError(OmniProtocolError): """Error occurred during message fragmentation or reassembly.""" -class OmniConnectionException(OmniLogicException): +class OmniConnectionError(OmniLogicError): """Network connection error occurred.""" -class OmniValidationException(OmniLogicException): +class OmniValidationError(OmniLogicError): """Invalid parameter or configuration value provided.""" -class OmniCommandException(OmniLogicException): +class OmniCommandError(OmniLogicError): """Error occurred while executing a command on the controller.""" diff --git a/pyomnilogic_local/api/protocol.py b/pyomnilogic_local/api/protocol.py index 5c7c43d..3e383bc 100644 --- a/pyomnilogic_local/api/protocol.py +++ b/pyomnilogic_local/api/protocol.py @@ -7,12 +7,11 @@ import time import xml.etree.ElementTree as ET import zlib -from typing import Any, cast +from typing import Any, Self, cast -from typing_extensions import Self +from pyomnilogic_local.models.leadmessage import LeadMessage +from pyomnilogic_local.omnitypes import ClientType, MessageType -from ..models.leadmessage import LeadMessage -from ..omnitypes import ClientType, MessageType from .constants import ( ACK_WAIT_TIMEOUT, BLOCK_MESSAGE_HEADER_OFFSET, @@ -27,17 +26,17 @@ XML_NAMESPACE, ) from .exceptions import ( - OmniFragmentationException, - OmniMessageFormatException, - OmniTimeoutException, + OmniFragmentationError, + OmniMessageFormatError, + OmniTimeoutError, ) _LOGGER = logging.getLogger(__name__) class OmniLogicMessage: - """ - Represents a protocol message for communication with the OmniLogic controller. + """A protocol message for communication with the OmniLogic controller. + Handles serialization and deserialization of message headers and payloads. """ @@ -59,8 +58,8 @@ def __init__( payload: str | None = None, version: str = PROTOCOL_VERSION, ) -> None: - """ - Initialize a new OmniLogicMessage. + """Initialize a new OmniLogicMessage. + Args: msg_id: Unique message identifier. msg_type: Type of message being sent. @@ -78,8 +77,8 @@ def __init__( self.version = version def __bytes__(self) -> bytes: - """ - Serialize the message to bytes for UDP transmission. + """Serialize the message to bytes for UDP transmission. + Returns: Byte representation of the message. """ @@ -97,9 +96,7 @@ def __bytes__(self) -> bytes: return header + self.payload def __repr__(self) -> str: - """ - Return a string representation of the message for debugging. - """ + """Return a string representation of the message for debugging.""" if self.compressed or self.type is MessageType.MSP_BLOCKMESSAGE: return f"ID: {self.id}, Type: {self.type.name}, Compressed: {self.compressed}, Client: {self.client_type.name}" return ( @@ -109,17 +106,20 @@ def __repr__(self) -> str: @classmethod def from_bytes(cls, data: bytes) -> Self: - """ - Parse a message from its byte representation. + """Parse a message from its byte representation. + Args: data: Byte data received from the controller. + Returns: OmniLogicMessage instance. + Raises: OmniMessageFormatException: If the message format is invalid. """ if len(data) < PROTOCOL_HEADER_SIZE: - raise OmniMessageFormatException(f"Message too short: {len(data)} bytes, expected at least {PROTOCOL_HEADER_SIZE}") + msg = f"Message too short: {len(data)} bytes, expected at least {PROTOCOL_HEADER_SIZE}" + raise OmniMessageFormatError(msg) # split the header and data header = data[:PROTOCOL_HEADER_SIZE] @@ -128,19 +128,22 @@ def from_bytes(cls, data: bytes) -> Self: try: (msg_id, tstamp, vers, msg_type, client_type, res1, compressed, res2) = struct.unpack(cls.header_format, header) except struct.error as exc: - raise OmniMessageFormatException(f"Failed to unpack message header: {exc}") from exc + msg = f"Failed to unpack message header: {exc}" + raise OmniMessageFormatError(msg) from exc # Validate message type try: message_type_enum = MessageType(msg_type) except ValueError as exc: - raise OmniMessageFormatException(f"Unknown message type: {msg_type}") from exc + msg = f"Unknown message type: {msg_type}: {exc}" + raise OmniMessageFormatError(msg) from exc # Validate client type try: client_type_enum = ClientType(int(client_type)) except ValueError as exc: - raise OmniMessageFormatException(f"Unknown client type: {client_type}") from exc + msg = f"Unknown client type: {client_type}: {exc}" + raise OmniMessageFormatError(msg) from exc message = cls(msg_id=msg_id, msg_type=message_type_enum, version=vers.decode("utf-8")) message.timestamp = tstamp @@ -155,8 +158,8 @@ def from_bytes(cls, data: bytes) -> Self: class OmniLogicProtocol(asyncio.DatagramProtocol): - """ - Asyncio DatagramProtocol implementation for OmniLogic UDP communication. + """Asyncio DatagramProtocol implementation for OmniLogic UDP communication. + Handles message sending, receiving, retries, and block message reassembly. """ @@ -170,28 +173,22 @@ class OmniLogicProtocol(asyncio.DatagramProtocol): error_queue: asyncio.Queue[Exception] def __init__(self) -> None: - """ - Initialize the protocol handler and message queue. - """ + """Initialize the protocol handler and message queue.""" self.data_queue = asyncio.Queue(maxsize=MAX_QUEUE_SIZE) self.error_queue = asyncio.Queue(maxsize=MAX_QUEUE_SIZE) def connection_made(self, transport: asyncio.BaseTransport) -> None: - """ - Called when a UDP connection is made. - """ - self.transport = cast(asyncio.DatagramTransport, transport) + """Called when a UDP connection is made.""" + self.transport = cast("asyncio.DatagramTransport", transport) def connection_lost(self, exc: Exception | None) -> None: - """ - Called when the UDP connection is lost or closed. - """ + """Called when the UDP connection is lost or closed.""" if exc: raise exc def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None: - """ - Called when a datagram is received from the controller. + """Called when a datagram is received from the controller. + Parses the message and puts it on the queue. Handles corrupt or unexpected data gracefully. """ try: @@ -200,25 +197,29 @@ def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None: try: self.data_queue.put_nowait(message) except asyncio.QueueFull: - _LOGGER.error("Data queue is full. Dropping message: %s", str(message)) - except OmniMessageFormatException as exc: - _LOGGER.error("Failed to parse incoming datagram from %s: %s", addr, exc) + _LOGGER.exception("Data queue is full. Dropping message: %s", str(message)) + except OmniMessageFormatError as exc: + _LOGGER.exception("Failed to parse incoming datagram from %s", addr) self.error_queue.put_nowait(exc) - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.error("Unexpected error processing datagram from %s: %s", addr, exc, exc_info=True) + except Exception as exc: + _LOGGER.exception("Unexpected error processing datagram from %s", addr) self.error_queue.put_nowait(exc) def error_received(self, exc: Exception) -> None: - """ - Called when a UDP error is received. + """Called when a UDP error is received. + Store the error so it can be handled by awaiting coroutines. """ self.error_queue.put_nowait(exc) async def _wait_for_ack(self, ack_id: int) -> None: - """ - Wait for an ACK message with the given ID. + """Wait for an ACK message with the given ID. + Handles dropped or out-of-order ACKs. + + Args: + ack_id: The message ID to wait for an ACK. + Raises: OmniTimeoutException: If no ACK is received. Exception: If a protocol error occurs. @@ -250,15 +251,16 @@ async def _ensure_sent( message: OmniLogicMessage, max_attempts: int = 5, ) -> None: - """ - Send a message and ensure it is acknowledged, retrying if necessary. + """Send a message and ensure it is acknowledged, retrying if necessary. + Args: message: The message to send. max_attempts: Maximum number of send attempts. + Raises: OmniTimeoutException: If no ACK is received after retries. """ - for attempt in range(0, max_attempts): + for attempt in range(max_attempts): self.transport.sendto(bytes(message)) _LOGGER.debug("Sent message ID %s (attempt %d/%d)", message.id, attempt + 1, max_attempts) @@ -269,7 +271,6 @@ async def _ensure_sent( # Wait for a bit to either receive an ACK for our message, otherwise, we retry delivery try: await asyncio.wait_for(self._wait_for_ack(message.id), ACK_WAIT_TIMEOUT) - return except TimeoutError as exc: if attempt < max_attempts - 1: _LOGGER.warning( @@ -280,10 +281,13 @@ async def _ensure_sent( max_attempts, ) else: - _LOGGER.error( + _LOGGER.exception( "Failed to receive ACK for message type %s (ID: %s) after %d attempts.", message.type.name, message.id, max_attempts ) - raise OmniTimeoutException("Failed to receive acknowledgement of command, max retries exceeded") from exc + msg = f"Failed to receive acknowledgement of command, max retries exceeded: {exc}" + raise OmniTimeoutError(msg) from exc + else: + return async def send_and_receive( self, @@ -291,18 +295,18 @@ async def send_and_receive( payload: str | None, msg_id: int | None = None, ) -> str: - """ - Send a message and wait for a response, returning the response payload as a string. + """Send a message and wait for a response, returning the response payload as a string. + Args: msg_type: Type of message to send. payload: Optional payload string. msg_id: Optional message ID. + Returns: Response payload as a string. """ await self.send_message(msg_type, payload, msg_id) - resp = await self._receive_file() - return resp + return await self._receive_file() # Send a message that you do NOT need a response to async def send_message( @@ -311,8 +315,8 @@ async def send_message( payload: str | None, msg_id: int | None = None, ) -> None: - """ - Send a message that does not require a response. + """Send a message that does not require a response. + Args: msg_type: Type of message to send. payload: Optional payload string. @@ -329,9 +333,7 @@ async def send_message( await self._ensure_sent(message) async def _send_ack(self, msg_id: int) -> None: - """ - Send an ACK message for the given message ID. - """ + """Send an ACK message for the given message ID.""" body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "Ack" @@ -340,11 +342,13 @@ async def _send_ack(self, msg_id: int) -> None: await self.send_message(MessageType.XML_ACK, req_body, msg_id) async def _receive_file(self) -> str: - """ - Wait for and reassemble a full response from the controller. + """Wait for and reassemble a full response from the controller. + Handles single and multi-block (LeadMessage/BlockMessage) responses. + Returns: Response payload as a string. + Raises: OmniTimeoutException: If a block message is not received in time. OmniFragmentationException: If fragment reassembly fails. @@ -365,7 +369,8 @@ async def _receive_file(self) -> str: try: leadmsg = LeadMessage.model_validate(ET.fromstring(message.payload[:-1])) except Exception as exc: - raise OmniFragmentationException(f"Failed to parse LeadMessage: {exc}") from exc + msg = f"Failed to parse LeadMessage: {exc}" + raise OmniFragmentationError(msg) from exc _LOGGER.debug("Will receive %s blockmessages for fragmented response", leadmsg.msg_block_count) @@ -379,17 +384,24 @@ async def _receive_file(self) -> str: while len(data_fragments) < leadmsg.msg_block_count: # Check if we've been waiting too long for fragments if time.time() - fragment_start_time > MAX_FRAGMENT_WAIT_TIME: - raise OmniFragmentationException( - f"Timeout waiting for fragments: received {len(data_fragments)}/{leadmsg.msg_block_count} after {MAX_FRAGMENT_WAIT_TIME}s" + _LOGGER.error( + "Timeout waiting for fragments: received %d/%d after %ds", + len(data_fragments), + leadmsg.msg_block_count, + MAX_FRAGMENT_WAIT_TIME, + ) + msg = ( + f"Timeout waiting for fragments: received {len(data_fragments)}/{leadmsg.msg_block_count} " + f"after {MAX_FRAGMENT_WAIT_TIME}s" ) + raise OmniFragmentationError(msg) # We need to wait long enough for the Omni to get through all of it's retries before we bail out. try: resp = await asyncio.wait_for(self.data_queue.get(), self._omni_retransmit_time * self._omni_retransmit_count) except TimeoutError as exc: - raise OmniFragmentationException( - f"Timeout receiving fragment: got {len(data_fragments)}/{leadmsg.msg_block_count} fragments" - ) from exc + msg = f"Timeout receiving fragment: got {len(data_fragments)}/{leadmsg.msg_block_count} fragments: {exc}" + raise OmniFragmentationError(msg) from exc # We only want to collect blockmessages here if resp.type is not MessageType.MSP_BLOCKMESSAGE: @@ -420,7 +432,8 @@ async def _receive_file(self) -> str: retval = zlib.decompress(comp_bytes) _LOGGER.debug("Decompressed %d bytes to %d bytes", len(comp_bytes), len(retval)) except zlib.error as exc: - raise OmniMessageFormatException(f"Failed to decompress message: {exc}") from exc + msg = f"Failed to decompress message: {exc}" + raise OmniMessageFormatError(msg) from exc # For some API calls, the Omni null terminates the response, we are stripping that here to make parsing it later easier return retval.decode("utf-8").strip("\x00") diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py index b958db6..17d8a10 100644 --- a/pyomnilogic_local/backyard.py +++ b/pyomnilogic_local/backyard.py @@ -5,7 +5,7 @@ from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.models.mspconfig import MSPBackyard -from pyomnilogic_local.models.telemetry import Telemetry, TelemetryBackyard +from pyomnilogic_local.models.telemetry import TelemetryBackyard from pyomnilogic_local.omnitypes import BackyardState from ._base import OmniEquipment @@ -15,6 +15,7 @@ from .sensor import Sensor if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic _LOGGER = logging.getLogger(__name__) diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py index 6f4f053..e095f38 100644 --- a/pyomnilogic_local/bow.py +++ b/pyomnilogic_local/bow.py @@ -12,15 +12,16 @@ from pyomnilogic_local.filter import Filter from pyomnilogic_local.heater import Heater from pyomnilogic_local.models.mspconfig import MSPBoW -from pyomnilogic_local.models.telemetry import Telemetry, TelemetryBoW -from pyomnilogic_local.omnitypes import BodyOfWaterType +from pyomnilogic_local.models.telemetry import TelemetryBoW from pyomnilogic_local.pump import Pump from pyomnilogic_local.relay import Relay from pyomnilogic_local.sensor import Sensor from pyomnilogic_local.util import OmniEquipmentNotInitializedError if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import BodyOfWaterType _LOGGER = logging.getLogger(__name__) diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py index 1135c74..83e137c 100644 --- a/pyomnilogic_local/chlorinator.py +++ b/pyomnilogic_local/chlorinator.py @@ -7,16 +7,14 @@ from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPChlorinator -from pyomnilogic_local.models.telemetry import Telemetry, TelemetryChlorinator -from pyomnilogic_local.omnitypes import ( - ChlorinatorCellType, - ChlorinatorOperatingMode, - ChlorinatorStatus, -) +from pyomnilogic_local.models.telemetry import TelemetryChlorinator +from pyomnilogic_local.omnitypes import ChlorinatorStatus from pyomnilogic_local.util import OmniEquipmentNotInitializedError if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import ChlorinatorCellType, ChlorinatorOperatingMode class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): @@ -367,7 +365,8 @@ async def turn_on(self) -> None: OmniEquipmentNotInitializedError: If bow_id is None. """ if self.bow_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn on chlorinator: bow_id is None") + msg = "Cannot turn on chlorinator: bow_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_chlorinator_enable(self.bow_id, True) @control_method @@ -378,7 +377,8 @@ async def turn_off(self) -> None: OmniEquipmentNotInitializedError: If bow_id is None. """ if self.bow_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn off chlorinator: bow_id is None") + msg = "Cannot turn off chlorinator: bow_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_chlorinator_enable(self.bow_id, False) @control_method @@ -398,15 +398,18 @@ async def set_timed_percent(self, percent: int) -> None: mspconfig are used for unchanged parameters. """ if self.bow_id is None or self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot set timed percent: bow_id or system_id is None") + msg = "Cannot set timed percent: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) if not 0 <= percent <= 100: - raise ValueError(f"Timed percent {percent} is outside valid range [0, 100]") + msg = f"Timed percent {percent} is outside valid range [0, 100]" + raise ValueError(msg) # Get the parent Bow to determine bow_type # We need to find our bow in the backyard if (bow := self._omni.backyard.bow.get(self.bow_id)) is None: - raise OmniEquipmentNotInitializedError(f"Cannot find bow with id {self.bow_id}") + msg = f"Cannot find bow with id {self.bow_id}" + raise OmniEquipmentNotInitializedError(msg) # Map equipment type to numeric bow_type value # BOW_POOL = 0, BOW_SPA = 1 (based on typical protocol values) diff --git a/pyomnilogic_local/chlorinator_equip.py b/pyomnilogic_local/chlorinator_equip.py index 48e29f7..2473dc0 100644 --- a/pyomnilogic_local/chlorinator_equip.py +++ b/pyomnilogic_local/chlorinator_equip.py @@ -4,11 +4,11 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.models.mspconfig import MSPChlorinatorEquip -from pyomnilogic_local.models.telemetry import Telemetry -from pyomnilogic_local.omnitypes import ChlorinatorType if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import ChlorinatorType class ChlorinatorEquipment(OmniEquipment[MSPChlorinatorEquip, None]): diff --git a/pyomnilogic_local/cli/debug/__init__.py b/pyomnilogic_local/cli/debug/__init__.py index e69de29..099dadf 100644 --- a/pyomnilogic_local/cli/debug/__init__.py +++ b/pyomnilogic_local/cli/debug/__init__.py @@ -0,0 +1 @@ +"""CLI commands for debugging the Hayward OmniLogic Local API.""" diff --git a/pyomnilogic_local/cli/debug/commands.py b/pyomnilogic_local/cli/debug/commands.py index 01430ac..56c65b1 100644 --- a/pyomnilogic_local/cli/debug/commands.py +++ b/pyomnilogic_local/cli/debug/commands.py @@ -5,14 +5,17 @@ import asyncio from pathlib import Path +from typing import TYPE_CHECKING import click -from pyomnilogic_local.api.api import OmniLogicAPI from pyomnilogic_local.cli import ensure_connection from pyomnilogic_local.cli.pcap_utils import parse_pcap_file, process_pcap_messages from pyomnilogic_local.cli.utils import async_get_filter_diagnostics +if TYPE_CHECKING: + from pyomnilogic_local.api.api import OmniLogicAPI + @click.group() @click.option("--raw/--no-raw", default=False, help="Output the raw XML from the OmniLogic, do not parse the response") @@ -39,6 +42,7 @@ def get_mspconfig(ctx: click.Context) -> None: Example: omnilogic debug get-mspconfig omnilogic debug --raw get-mspconfig + """ ensure_connection(ctx) omni: OmniLogicAPI = ctx.obj["OMNI"] @@ -57,6 +61,7 @@ def get_telemetry(ctx: click.Context) -> None: Example: omnilogic debug get-telemetry omnilogic debug --raw get-telemetry + """ ensure_connection(ctx) omni: OmniLogicAPI = ctx.obj["OMNI"] @@ -78,6 +83,7 @@ def get_filter_diagnostics(ctx: click.Context, pool_id: int, filter_id: int) -> Example: omnilogic debug get-filter-diagnostics --pool-id 1 --filter-id 5 + """ ensure_connection(ctx) filter_diags = asyncio.run(async_get_filter_diagnostics(ctx.obj["OMNI"], pool_id, filter_id, ctx.obj["RAW"])) @@ -105,8 +111,7 @@ def get_filter_diagnostics(ctx: click.Context, pool_id: int, filter_id: int) -> @debug.command() @click.argument("pcap_file", type=click.Path(exists=True, path_type=Path)) -@click.pass_context -def parse_pcap(ctx: click.Context, pcap_file: Path) -> None: +def parse_pcap(pcap_file: Path) -> None: """Parse a PCAP file and reconstruct OmniLogic protocol communication. Analyzes network packet captures to decode OmniLogic protocol messages. @@ -120,13 +125,14 @@ def parse_pcap(ctx: click.Context, pcap_file: Path) -> None: omnilogic debug parse-pcap /path/to/capture.pcap tcpdump -i eth0 -w pool.pcap udp port 10444 omnilogic debug parse-pcap pool.pcap + """ # Read the PCAP file try: packets = parse_pcap_file(str(pcap_file)) except Exception as e: click.echo(f"Error reading PCAP file: {e}", err=True) - raise click.Abort() + raise click.Abort from e # Process all packets and extract OmniLogic messages results = process_pcap_messages(packets) @@ -170,6 +176,7 @@ def set_equipment(ctx: click.Context, bow_id: int, equip_id: int, is_on: str) -> # Turn off pump (0% speed) omnilogic --host 192.168.1.100 debug set-equipment 7 8 0 + """ ensure_connection(ctx) omni: OmniLogicAPI = ctx.obj["OMNI"] @@ -186,10 +193,10 @@ def set_equipment(ctx: click.Context, bow_id: int, equip_id: int, is_on: str) -> is_on_value = int(is_on) if not 0 <= is_on_value <= 100: click.echo(f"Error: Integer value must be between 0-100, got {is_on_value}", err=True) - raise click.Abort() + raise click.Abort except ValueError as exc: click.echo(f"Error: Invalid value '{is_on}'. Use true/false, on/off, or 0-100 for speed.", err=True) - raise click.Abort() from exc + raise click.Abort from exc # Execute the command try: @@ -201,4 +208,4 @@ def set_equipment(ctx: click.Context, bow_id: int, equip_id: int, is_on: str) -> click.echo(f"Successfully set equipment {equip_id} in BOW {bow_id} to {is_on_value}%") except Exception as e: click.echo(f"Error setting equipment: {e}", err=True) - raise click.Abort() + raise click.Abort from e diff --git a/pyomnilogic_local/cli/get/__init__.py b/pyomnilogic_local/cli/get/__init__.py index e69de29..f06b4f3 100644 --- a/pyomnilogic_local/cli/get/__init__.py +++ b/pyomnilogic_local/cli/get/__init__.py @@ -0,0 +1 @@ +"""CLI commands for retrieving data from the Hayward OmniLogic Local API.""" diff --git a/pyomnilogic_local/cli/get/backyard.py b/pyomnilogic_local/cli/get/backyard.py index 0b57d4b..55bad33 100644 --- a/pyomnilogic_local/cli/get/backyard.py +++ b/pyomnilogic_local/cli/get/backyard.py @@ -3,21 +3,15 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPBackyard, - MSPConfig, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryType, -) -from pyomnilogic_local.omnitypes import ( - BackyardState, -) +from pyomnilogic_local.omnitypes import BackyardState + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPBackyard, MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryType @click.command() @@ -42,7 +36,7 @@ def _print_backyard_info(backyardconfig: MSPBackyard, telemetry: TelemetryType | """Format and print backyard information in a nice table format. Args: - backyard: Backyard object from MSPConfig with attributes to display + backyardconfig: Backyard object from MSPConfig with attributes to display telemetry: Telemetry object containing current state information """ click.echo("\n" + "=" * 60) @@ -77,8 +71,7 @@ def _print_backyard_info(backyardconfig: MSPBackyard, telemetry: TelemetryType | if backyardconfig.bow: equipment_counts.append(f"Bodies of Water: {len(backyardconfig.bow)}") - for bow in backyardconfig.bow: - equipment_counts.append(f" - {bow.name} ({bow.equip_type})") + equipment_counts.extend(f" - {bow.name} ({bow.equip_type})" for bow in backyardconfig.bow) if backyardconfig.sensor: equipment_counts.append(f"Backyard Sensors: {len(backyardconfig.sensor)}") diff --git a/pyomnilogic_local/cli/get/bows.py b/pyomnilogic_local/cli/get/bows.py index 605d43b..d6538d7 100644 --- a/pyomnilogic_local/cli/get/bows.py +++ b/pyomnilogic_local/cli/get/bows.py @@ -3,21 +3,15 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPBoW, - MSPConfig, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryType, -) -from pyomnilogic_local.omnitypes import ( - BodyOfWaterType, -) +from pyomnilogic_local.omnitypes import BodyOfWaterType + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPBoW, MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryType @click.command() diff --git a/pyomnilogic_local/cli/get/chlorinators.py b/pyomnilogic_local/cli/get/chlorinators.py index 1777225..a0b538c 100644 --- a/pyomnilogic_local/cli/get/chlorinators.py +++ b/pyomnilogic_local/cli/get/chlorinators.py @@ -3,23 +3,15 @@ from __future__ import annotations -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import click -from pyomnilogic_local.models.mspconfig import ( - MSPChlorinator, - MSPConfig, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryChlorinator, -) -from pyomnilogic_local.omnitypes import ( - ChlorinatorCellType, - ChlorinatorDispenserType, - ChlorinatorOperatingMode, -) +from pyomnilogic_local.omnitypes import ChlorinatorCellType, ChlorinatorDispenserType, ChlorinatorOperatingMode + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPChlorinator, MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryChlorinator @click.command() @@ -44,7 +36,7 @@ def chlorinators(ctx: click.Context) -> None: if bow.chlorinator: chlorinators_found = True _print_chlorinator_info( - bow.chlorinator, cast(TelemetryChlorinator, telemetry.get_telem_by_systemid(bow.chlorinator.system_id)) + bow.chlorinator, cast("TelemetryChlorinator", telemetry.get_telem_by_systemid(bow.chlorinator.system_id)) ) if not chlorinators_found: diff --git a/pyomnilogic_local/cli/get/csads.py b/pyomnilogic_local/cli/get/csads.py index 2274f6f..645420e 100644 --- a/pyomnilogic_local/cli/get/csads.py +++ b/pyomnilogic_local/cli/get/csads.py @@ -3,22 +3,15 @@ from __future__ import annotations -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import click -from pyomnilogic_local.models.mspconfig import ( - MSPCSAD, - MSPConfig, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryCSAD, -) -from pyomnilogic_local.omnitypes import ( - CSADMode, - CSADType, -) +from pyomnilogic_local.omnitypes import CSADMode, CSADType + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPCSAD, MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryCSAD @click.command() @@ -43,7 +36,7 @@ def csads(ctx: click.Context) -> None: if bow.csad: for csad in bow.csad: csads_found = True - _print_csad_info(csad, cast(TelemetryCSAD, telemetry.get_telem_by_systemid(csad.system_id))) + _print_csad_info(csad, cast("TelemetryCSAD", telemetry.get_telem_by_systemid(csad.system_id))) if not csads_found: click.echo("No CSAD systems found in the system configuration.") diff --git a/pyomnilogic_local/cli/get/filters.py b/pyomnilogic_local/cli/get/filters.py index 010a2a5..cd23f83 100644 --- a/pyomnilogic_local/cli/get/filters.py +++ b/pyomnilogic_local/cli/get/filters.py @@ -3,24 +3,15 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPFilter, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryType, -) -from pyomnilogic_local.omnitypes import ( - FilterState, - FilterType, - FilterValvePosition, - FilterWhyOn, -) +from pyomnilogic_local.omnitypes import FilterState, FilterType, FilterValvePosition, FilterWhyOn + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPFilter + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryType @click.command() diff --git a/pyomnilogic_local/cli/get/groups.py b/pyomnilogic_local/cli/get/groups.py index 776442f..92750f5 100644 --- a/pyomnilogic_local/cli/get/groups.py +++ b/pyomnilogic_local/cli/get/groups.py @@ -3,21 +3,15 @@ from __future__ import annotations -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPGroup, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryGroup, -) -from pyomnilogic_local.omnitypes import ( - GroupState, -) +from pyomnilogic_local.omnitypes import GroupState + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPGroup + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryGroup @click.command() @@ -40,7 +34,7 @@ def groups(ctx: click.Context) -> None: if mspconfig.groups: for group in mspconfig.groups: groups_found = True - _print_group_info(group, cast(TelemetryGroup, telemetry.get_telem_by_systemid(group.system_id))) + _print_group_info(group, cast("TelemetryGroup", telemetry.get_telem_by_systemid(group.system_id))) if not groups_found: click.echo("No groups found in the system configuration.") diff --git a/pyomnilogic_local/cli/get/heaters.py b/pyomnilogic_local/cli/get/heaters.py index 52730b4..1e9b6e5 100644 --- a/pyomnilogic_local/cli/get/heaters.py +++ b/pyomnilogic_local/cli/get/heaters.py @@ -3,23 +3,15 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPHeaterEquip, - MSPVirtualHeater, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, -) -from pyomnilogic_local.omnitypes import ( - HeaterMode, - HeaterState, - HeaterType, -) +from pyomnilogic_local.omnitypes import HeaterMode, HeaterState, HeaterType + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPHeaterEquip, MSPVirtualHeater + from pyomnilogic_local.models.telemetry import Telemetry @click.command() diff --git a/pyomnilogic_local/cli/get/lights.py b/pyomnilogic_local/cli/get/lights.py index 9d0bf0f..b990fe3 100644 --- a/pyomnilogic_local/cli/get/lights.py +++ b/pyomnilogic_local/cli/get/lights.py @@ -3,23 +3,15 @@ from __future__ import annotations -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import click -from pyomnilogic_local.models.mspconfig import ( - MSPColorLogicLight, - MSPConfig, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryColorLogicLight, -) -from pyomnilogic_local.omnitypes import ( - ColorLogicBrightness, - ColorLogicPowerState, - ColorLogicSpeed, -) +from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicPowerState, ColorLogicSpeed + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPColorLogicLight, MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryColorLogicLight @click.command() @@ -42,7 +34,7 @@ def lights(ctx: click.Context) -> None: if mspconfig.backyard.colorlogic_light: for light in mspconfig.backyard.colorlogic_light: lights_found = True - _print_light_info(light, cast(TelemetryColorLogicLight, telemetry.get_telem_by_systemid(light.system_id))) + _print_light_info(light, cast("TelemetryColorLogicLight", telemetry.get_telem_by_systemid(light.system_id))) # Check for lights in Bodies of Water if mspconfig.backyard.bow: @@ -50,7 +42,7 @@ def lights(ctx: click.Context) -> None: if bow.colorlogic_light: for cl_light in bow.colorlogic_light: lights_found = True - _print_light_info(cl_light, cast(TelemetryColorLogicLight, telemetry.get_telem_by_systemid(cl_light.system_id))) + _print_light_info(cl_light, cast("TelemetryColorLogicLight", telemetry.get_telem_by_systemid(cl_light.system_id))) if not lights_found: click.echo("No ColorLogic lights found in the system configuration.") @@ -76,7 +68,7 @@ def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryColorLogicL show_names = [show.pretty() if hasattr(show, "pretty") else str(show) for show in value] value = ", ".join(show_names) if show_names else "None" elif attr_name == "show" and value is not None: - value = telemetry.show_name(light.equip_type, light.v2_active, True) if telemetry else str(value) + value = telemetry.show_name(light.equip_type, light.v2_active) if telemetry else str(value) elif attr_name == "speed": value = ColorLogicSpeed(value).pretty() elif attr_name == "state": diff --git a/pyomnilogic_local/cli/get/pumps.py b/pyomnilogic_local/cli/get/pumps.py index 189ee36..7e73d92 100644 --- a/pyomnilogic_local/cli/get/pumps.py +++ b/pyomnilogic_local/cli/get/pumps.py @@ -3,23 +3,15 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPPump, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryType, -) -from pyomnilogic_local.omnitypes import ( - PumpFunction, - PumpState, - PumpType, -) +from pyomnilogic_local.omnitypes import PumpFunction, PumpState, PumpType + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPPump + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryType @click.command() diff --git a/pyomnilogic_local/cli/get/relays.py b/pyomnilogic_local/cli/get/relays.py index 9cff405..a3237dd 100644 --- a/pyomnilogic_local/cli/get/relays.py +++ b/pyomnilogic_local/cli/get/relays.py @@ -3,24 +3,15 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPRelay, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryType, -) -from pyomnilogic_local.omnitypes import ( - RelayFunction, - RelayState, - RelayType, - RelayWhyOn, -) +from pyomnilogic_local.omnitypes import RelayFunction, RelayState, RelayType, RelayWhyOn + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPRelay + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryType @click.command() diff --git a/pyomnilogic_local/cli/get/schedules.py b/pyomnilogic_local/cli/get/schedules.py index 02e3633..13d73c0 100644 --- a/pyomnilogic_local/cli/get/schedules.py +++ b/pyomnilogic_local/cli/get/schedules.py @@ -3,14 +3,12 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPSchedule, -) +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPSchedule @click.command() @@ -55,7 +53,6 @@ def _print_schedule_info(schedule: MSPSchedule) -> None: continue if attr_name == "event": value = value.pretty() - # value = GroupState(value).pretty() if isinstance(value, list): # Format lists nicely value = ", ".join(str(v) for v in value) if value else "None" diff --git a/pyomnilogic_local/cli/get/sensors.py b/pyomnilogic_local/cli/get/sensors.py index 6b9e208..d847c8e 100644 --- a/pyomnilogic_local/cli/get/sensors.py +++ b/pyomnilogic_local/cli/get/sensors.py @@ -3,18 +3,14 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPSensor, -) -from pyomnilogic_local.omnitypes import ( - SensorType, - SensorUnits, -) +from pyomnilogic_local.omnitypes import SensorType, SensorUnits + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPSensor @click.command() diff --git a/pyomnilogic_local/cli/get/valves.py b/pyomnilogic_local/cli/get/valves.py index 6cf3c1d..e7ffd3c 100644 --- a/pyomnilogic_local/cli/get/valves.py +++ b/pyomnilogic_local/cli/get/valves.py @@ -3,23 +3,15 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPRelay, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, -) -from pyomnilogic_local.omnitypes import ( - RelayFunction, - RelayType, - RelayWhyOn, - ValveActuatorState, -) +from pyomnilogic_local.omnitypes import RelayFunction, RelayType, RelayWhyOn, ValveActuatorState + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPRelay + from pyomnilogic_local.models.telemetry import Telemetry @click.command() diff --git a/pyomnilogic_local/cli/pcap_utils.py b/pyomnilogic_local/cli/pcap_utils.py index d828949..cdfa036 100644 --- a/pyomnilogic_local/cli/pcap_utils.py +++ b/pyomnilogic_local/cli/pcap_utils.py @@ -10,16 +10,18 @@ import xml.etree.ElementTree as ET import zlib from collections import defaultdict -from typing import Any +from typing import TYPE_CHECKING, Any from scapy.layers.inet import UDP -from scapy.packet import Packet from scapy.utils import rdpcap from pyomnilogic_local.api.protocol import OmniLogicMessage from pyomnilogic_local.models.leadmessage import LeadMessage from pyomnilogic_local.omnitypes import MessageType +if TYPE_CHECKING: + from scapy.packet import Packet + def parse_pcap_file(pcap_path: str) -> Any: """Read and parse a PCAP file. @@ -56,9 +58,10 @@ def extract_omnilogic_message(packet: Packet) -> tuple[OmniLogicMessage, str, st # Not an OmniLogic message try: omni_msg = OmniLogicMessage.from_bytes(bytes(udp.payload)) - return omni_msg, src_ip, dst_ip - except Exception: # pylint: disable=broad-except + except Exception: return None + else: + return omni_msg, src_ip, dst_ip def reassemble_message_blocks(messages: list[OmniLogicMessage]) -> str: @@ -93,9 +96,7 @@ def reassemble_message_blocks(messages: list[OmniLogicMessage]) -> str: reassembled = zlib.decompress(reassembled) # Decode to string - decoded = reassembled.decode("utf-8").strip("\x00") - - return decoded + return reassembled.decode("utf-8").strip("\x00") def process_pcap_messages(packets: Any) -> list[tuple[str, str, OmniLogicMessage, str | None]]: @@ -137,10 +138,8 @@ def process_pcap_messages(packets: Any) -> list[tuple[str, str, OmniLogicMessage # Find the matching LeadMessage sequence matching_seq: tuple[str, str, int] | None = None for seq_key in message_sequences: - if seq_key[0] == src_ip and seq_key[1] == dst_ip: - # Check if this is the right sequence - if not matching_seq or seq_key[2] > matching_seq[2]: # pylint: disable=unsubscriptable-object - matching_seq = seq_key + if (seq_key[0] == src_ip and seq_key[1] == dst_ip) and (not matching_seq or seq_key[2] > matching_seq[2]): + matching_seq = seq_key if matching_seq: message_sequences[matching_seq].append(omni_msg) @@ -156,7 +155,7 @@ def process_pcap_messages(packets: Any) -> list[tuple[str, str, OmniLogicMessage decoded_msg = reassemble_message_blocks(message_sequences[matching_seq]) # Add the reassembled message result results.append((src_ip, dst_ip, lead_msg, decoded_msg)) - except Exception: # pylint: disable=broad-except + except Exception: pass # Clean up this sequence diff --git a/pyomnilogic_local/cli/utils.py b/pyomnilogic_local/cli/utils.py index 419beeb..ce61cb8 100644 --- a/pyomnilogic_local/cli/utils.py +++ b/pyomnilogic_local/cli/utils.py @@ -7,14 +7,16 @@ from __future__ import annotations import asyncio -from typing import Literal, overload +from typing import TYPE_CHECKING, Literal, overload import click from pyomnilogic_local.api.api import OmniLogicAPI -from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics -from pyomnilogic_local.models.mspconfig import MSPConfig -from pyomnilogic_local.models.telemetry import Telemetry + +if TYPE_CHECKING: + from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics + from pyomnilogic_local.models.mspconfig import MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry async def get_omni(host: str) -> OmniLogicAPI: @@ -45,7 +47,8 @@ async def fetch_startup_data(omni: OmniLogicAPI) -> tuple[MSPConfig, Telemetry]: mspconfig = await omni.async_get_mspconfig() telemetry = await omni.async_get_telemetry() except Exception as exc: - raise RuntimeError(f"[ERROR] Failed to fetch config or telemetry from controller: {exc}") from exc + msg = f"[ERROR] Failed to fetch config or telemetry from controller: {exc}" + raise RuntimeError(msg) from exc return mspconfig, telemetry @@ -71,7 +74,7 @@ def ensure_connection(ctx: click.Context) -> None: try: omni = asyncio.run(get_omni(host)) mspconfig, telemetry = asyncio.run(fetch_startup_data(omni)) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: click.secho(str(exc), fg="red", err=True) ctx.exit(1) @@ -100,5 +103,4 @@ async def async_get_filter_diagnostics(omni: OmniLogicAPI, pool_id: int, filter_ Returns: FilterDiagnostics object or raw XML string depending on raw parameter """ - filter_diags = await omni.async_get_filter_diagnostics(pool_id, filter_id, raw=raw) - return filter_diags + return await omni.async_get_filter_diagnostics(pool_id, filter_id, raw=raw) diff --git a/pyomnilogic_local/collections.py b/pyomnilogic_local/collections.py index d1feed3..be1f39b 100644 --- a/pyomnilogic_local/collections.py +++ b/pyomnilogic_local/collections.py @@ -4,23 +4,22 @@ import logging from collections import Counter -from collections.abc import Iterator from enum import Enum -from typing import Any, Generic, TypeVar, overload +from typing import TYPE_CHECKING, Any, overload from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.omnitypes import LightShows +if TYPE_CHECKING: + from collections.abc import Iterator + _LOGGER = logging.getLogger(__name__) # Track which duplicate names we've already warned about to avoid log spam _WARNED_DUPLICATE_NAMES: set[str] = set() -# Type variable for equipment types -OE = TypeVar("OE", bound=OmniEquipment[Any, Any]) - -class EquipmentDict(Generic[OE]): +class EquipmentDict[OE: OmniEquipment[Any, Any]]: """A dictionary-like collection that supports lookup by both name and system_id. This collection allows accessing equipment using either their name (str) or @@ -82,11 +81,12 @@ def _validate(self) -> None: """ # Check for items with no system_id AND no name if invalid_items := [item for item in self._items if item.system_id is None and item.name is None]: - raise ValueError( + msg = ( f"Equipment collection contains {len(invalid_items)} item(s) " "with neither a system_id nor a name. All equipment must have " "at least one identifier for addressing." ) + 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) @@ -143,7 +143,8 @@ def __getitem__(self, key: str | int) -> OE: if isinstance(key, int): return self._by_id[key] - raise TypeError(f"Key must be str or int, got {type(key).__name__}") + msg = f"Key must be str or int, got {type(key).__name__}" + raise TypeError(msg) def __setitem__(self, key: str | int, value: OE) -> None: """Add or update equipment in the collection. @@ -167,12 +168,15 @@ def __setitem__(self, key: str | int, value: OE) -> None: """ if isinstance(key, str): if value.name != key: - raise ValueError(f"Equipment name '{value.name}' does not match key '{key}'") + msg = f"Equipment name '{value.name}' does not match key '{key}'" + raise ValueError(msg) elif isinstance(key, int): if value.system_id != key: - raise ValueError(f"Equipment system_id {value.system_id} does not match key {key}") + msg = f"Equipment system_id {value.system_id} does not match key {key}" + raise ValueError(msg) else: - raise TypeError(f"Key must be str or int, got {type(key).__name__}") + msg = f"Key must be str or int, got {type(key).__name__}" + raise TypeError(msg) # Check if we're updating an existing item (prioritize system_id) existing_item = None @@ -265,7 +269,6 @@ def __repr__(self) -> str: String representation showing item count and names """ names = [f"" for item in self._items] - # names = [item.name or f"" for item in self._items] return f"EquipmentDict({names})" def append(self, item: OE) -> None: @@ -392,11 +395,7 @@ def items(self) -> list[tuple[int | None, str | None, OE]]: return [(item.system_id, item.name, item) for item in self._items] -# Type variable for enum types -E = TypeVar("E", bound=Enum) - - -class EffectsCollection(Generic[E]): +class EffectsCollection[E: Enum]: """A collection that provides both attribute and dict-like access to light effects. This class wraps a list of light shows and exposes them through multiple access patterns: @@ -447,12 +446,14 @@ def __getattr__(self, name: str) -> E: """ if name.startswith("_"): # Avoid infinite recursion for internal attributes - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + msg = f"'{type(self).__name__}' object has no attribute '{name}'" + raise AttributeError(msg) try: return self._effects_by_name[name] except KeyError as exc: - raise AttributeError(f"Light effect '{name}' is not available for this light model") from exc + msg = f"Light effect '{name}' is not available for this light model" + raise AttributeError(msg) from exc def __getitem__(self, key: str | int) -> E: """Enable dict-like and index access to effects. @@ -472,11 +473,13 @@ def __getitem__(self, key: str | int) -> E: try: return self._effects_by_name[key] except KeyError as exc: - raise KeyError(f"Light effect '{key}' is not available for this light model") from exc + msg = f"Light effect '{key}' is not available for this light model" + raise KeyError(msg) from exc elif isinstance(key, int): return self._effects[key] else: - raise TypeError(f"indices must be integers or strings, not {type(key).__name__}") + msg = f"indices must be integers or strings, not {type(key).__name__}" + raise TypeError(msg) def __contains__(self, item: str | E) -> bool: """Check if an effect is available in this collection. diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py index c9f310b..2e90878 100644 --- a/pyomnilogic_local/colorlogiclight.py +++ b/pyomnilogic_local/colorlogiclight.py @@ -7,20 +7,14 @@ from pyomnilogic_local.collections import LightEffectsCollection from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPColorLogicLight -from pyomnilogic_local.models.telemetry import Telemetry, TelemetryColorLogicLight -from pyomnilogic_local.omnitypes import ( - ColorLogicBrightness, - ColorLogicLightType, - ColorLogicPowerState, - ColorLogicSpeed, - LightShows, -) -from pyomnilogic_local.util import ( - OmniEquipmentNotInitializedError, -) +from pyomnilogic_local.models.telemetry import TelemetryColorLogicLight +from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicLightType, ColorLogicPowerState, ColorLogicSpeed +from pyomnilogic_local.util import OmniEquipmentNotInitializedError if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import LightShows _LOGGER = logging.getLogger(__name__) @@ -199,8 +193,7 @@ def special_effect(self) -> int: @property def is_ready(self) -> bool: - """ - Returns whether the light is ready to accept commands. + """Return whether the light is ready to accept commands. The light is not ready when: - The backyard is in service/config mode (checked by parent class) @@ -227,34 +220,33 @@ def is_ready(self) -> bool: @control_method async def turn_on(self) -> None: - """ - Turns the light on. + """Turn the light on. Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. """ if self.bow_id is None or self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn on light: bow_id or system_id is None") + msg = "Cannot turn on light: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_equipment(self.bow_id, self.system_id, True) @control_method async def turn_off(self) -> None: - """ - Turns the light off. + """Turn the light off. Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. """ if self.bow_id is None or self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn off light: bow_id or system_id is None") + msg = "Cannot turn off light: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_equipment(self.bow_id, self.system_id, False) @control_method async def set_show( self, show: LightShows | None = None, speed: ColorLogicSpeed | None = None, brightness: ColorLogicBrightness | None = None ) -> None: - """ - Sets the light show, speed, and brightness. + """Set the light show, speed, and brightness. Args: show: The light show to set. If None, uses the current show. @@ -269,7 +261,6 @@ async def set_show( If speed or brightness are provided for non color-logic lights, they will be ignored and a warning will be logged. """ - # Non color-logic lights do not support speed or brightness control if self.model not in [ ColorLogicLightType.SAM, @@ -285,7 +276,8 @@ async def set_show( brightness = ColorLogicBrightness.ONE_HUNDRED_PERCENT if self.bow_id is None or self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot set light show: bow_id or system_id is None") + msg = "Cannot set light show: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_light_show( self.bow_id, diff --git a/pyomnilogic_local/csad.py b/pyomnilogic_local/csad.py index 24bde06..3a98ce4 100644 --- a/pyomnilogic_local/csad.py +++ b/pyomnilogic_local/csad.py @@ -6,11 +6,13 @@ from pyomnilogic_local.collections import EquipmentDict from pyomnilogic_local.csad_equip import CSADEquipment from pyomnilogic_local.models.mspconfig import MSPCSAD -from pyomnilogic_local.models.telemetry import Telemetry, TelemetryCSAD -from pyomnilogic_local.omnitypes import CSADMode, CSADStatus, CSADType +from pyomnilogic_local.models.telemetry import TelemetryCSAD +from pyomnilogic_local.omnitypes import CSADMode, CSADStatus if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import CSADType class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]): @@ -83,12 +85,12 @@ def calibration_value(self) -> float: @property def ph_low_alarm(self) -> float: - """pH level that triggers a low pH alarm.""" + """Low pH threshold for triggering an alarm.""" return self.mspconfig.ph_low_alarm_value @property def ph_high_alarm(self) -> float: - """pH level that triggers a high pH alarm.""" + """High pH threshold for triggering an alarm.""" return self.mspconfig.ph_high_alarm_value @property diff --git a/pyomnilogic_local/csad_equip.py b/pyomnilogic_local/csad_equip.py index df07688..8ce1e1b 100644 --- a/pyomnilogic_local/csad_equip.py +++ b/pyomnilogic_local/csad_equip.py @@ -6,11 +6,11 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.models.mspconfig import MSPCSADEquip -from pyomnilogic_local.models.telemetry import Telemetry -from pyomnilogic_local.omnitypes import CSADEquipmentType if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import CSADEquipmentType class CSADEquipment(OmniEquipment[MSPCSADEquip, None]): diff --git a/pyomnilogic_local/decorators.py b/pyomnilogic_local/decorators.py index b1b369f..9252ad9 100644 --- a/pyomnilogic_local/decorators.py +++ b/pyomnilogic_local/decorators.py @@ -5,20 +5,15 @@ import functools import logging from collections.abc import Callable -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import Any, cast from pyomnilogic_local.util import OmniEquipmentNotReadyError -if TYPE_CHECKING: - pass - _LOGGER = logging.getLogger(__name__) -F = TypeVar("F", bound=Callable[..., Any]) - -def control_method(func: F) -> F: - """Decorator for equipment control methods that checks readiness and dirties state. +def control_method[F: Callable[..., Any]](func: F) -> F: + """Check readiness and mark state as dirty. This decorator ensures equipment is ready before executing control methods and automatically marks telemetry as dirty after execution. It replaces the common @@ -37,13 +32,6 @@ def control_method(func: F) -> F: @control_method async def turn_on(self) -> None: await self._api.async_set_equipment(...) - - # Replaces this pattern: - # @dirties_state() - # async def turn_on(self) -> None: - # if not self.is_ready: - # raise OmniEquipmentNotReadyError("Cannot turn on: equipment is not ready") - # await self._api.async_set_equipment(...) """ # Import here to avoid circular dependency @@ -61,10 +49,10 @@ async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: # Mark telemetry as dirty if hasattr(self, "_omni"): - self._omni._telemetry_dirty = True # pylint: disable=protected-access + self._omni._telemetry_dirty = True else: _LOGGER.warning("%s does not have _omni reference, cannot mark state as dirty", self.__class__.__name__) return result - return cast(F, wrapper) + return cast("F", wrapper) diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py index ac718e9..a467ec2 100644 --- a/pyomnilogic_local/filter.py +++ b/pyomnilogic_local/filter.py @@ -281,8 +281,8 @@ async def set_speed(self, speed: int) -> None: msg = "Filter bow_id and system_id must be set" raise OmniEquipmentNotInitializedError(msg) - if not 0 <= speed <= 100: - msg = f"Speed {speed} is outside valid range [0, 100]" + if not self.min_percent <= speed <= self.max_percent: + msg = f"Speed {speed} is outside valid range [{self.min_percent}, {self.max_percent}]" raise ValueError(msg) # Note: The API validates against min_percent/max_percent internally diff --git a/pyomnilogic_local/groups.py b/pyomnilogic_local/groups.py index 0e681f3..2c9be77 100644 --- a/pyomnilogic_local/groups.py +++ b/pyomnilogic_local/groups.py @@ -5,11 +5,12 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPGroup -from pyomnilogic_local.models.telemetry import Telemetry, TelemetryGroup +from pyomnilogic_local.models.telemetry import TelemetryGroup from pyomnilogic_local.omnitypes import GroupState from pyomnilogic_local.util import OmniEquipmentNotInitializedError if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic @@ -90,24 +91,24 @@ def is_on(self) -> bool: @control_method async def turn_on(self) -> None: - """ - Activates the group, turning on all equipment assigned to it. + """Activate the group, turning on all equipment assigned to it. Raises: OmniEquipmentNotInitializedError: If system_id is None. """ if self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn on group: system_id is None") + msg = "Cannot turn on group: system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_group_enable(self.system_id, True) @control_method async def turn_off(self) -> None: - """ - Deactivates the group, turning off all equipment assigned to it. + """Deactivate the group, turning off all equipment assigned to it. Raises: OmniEquipmentNotInitializedError: If system_id is None. """ if self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn off group: system_id is None") + msg = "Cannot turn off group: system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_group_enable(self.system_id, False) diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py index 2562344..5f2ee57 100644 --- a/pyomnilogic_local/heater.py +++ b/pyomnilogic_local/heater.py @@ -7,12 +7,13 @@ from pyomnilogic_local.decorators import control_method from pyomnilogic_local.heater_equip import HeaterEquipment from pyomnilogic_local.models.mspconfig import MSPVirtualHeater -from pyomnilogic_local.models.telemetry import Telemetry, TelemetryVirtualHeater -from pyomnilogic_local.omnitypes import HeaterMode +from pyomnilogic_local.models.telemetry import TelemetryVirtualHeater from pyomnilogic_local.util import OmniEquipmentNotInitializedError if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import HeaterMode class Heater(OmniEquipment[MSPVirtualHeater, TelemetryVirtualHeater]): @@ -86,7 +87,7 @@ class Heater(OmniEquipment[MSPVirtualHeater, TelemetryVirtualHeater]): - Use system.units to determine display preference (not API units) - If your application uses Celsius, convert before calling these methods - Example: + Example conversion: >>> # If working in Celsius >>> celsius_target = 29 >>> fahrenheit_target = (celsius_target * 9/5) + 32 @@ -122,8 +123,7 @@ def _update_heater_equipment(self, mspconfig: MSPVirtualHeater, telemetry: Telem @property def max_temp(self) -> int: - """ - Returns the maximum settable temperature. + """Returns the maximum settable temperature. Note: Temperature is always in Fahrenheit internally. Use the system.units property to determine if conversion to Celsius is needed for display. @@ -132,8 +132,7 @@ def max_temp(self) -> int: @property def min_temp(self) -> int: - """ - Returns the minimum settable temperature. + """Returns the minimum settable temperature. Note: Temperature is always in Fahrenheit internally. Use the system.units property to determine if conversion to Celsius is needed for display. @@ -147,8 +146,7 @@ def mode(self) -> HeaterMode | int: @property def current_set_point(self) -> int: - """ - Returns the current set point from telemetry. + """Returns the current set point from telemetry. Note: Temperature is always in Fahrenheit internally. Use the system.units property to determine if conversion to Celsius is needed for display. @@ -157,8 +155,7 @@ def current_set_point(self) -> int: @property def solar_set_point(self) -> int: - """ - Returns the solar set point from telemetry. + """Returns the solar set point from telemetry. Note: Temperature is always in Fahrenheit internally. Use the system.units property to determine if conversion to Celsius is needed for display. @@ -187,34 +184,33 @@ def is_on(self) -> bool: @control_method async def turn_on(self) -> None: - """ - Turns the heater on (enables it). + """Turn the heater on (enables it). Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. OmniEquipmentNotReadyError: If the equipment is not ready to accept commands. """ if self.bow_id is None or self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn on heater: bow_id or system_id is None") + msg = "Cannot turn on heater: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_heater_enable(self.bow_id, self.system_id, True) @control_method async def turn_off(self) -> None: - """ - Turns the heater off (disables it). + """Turn the heater off (disables it). Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. OmniEquipmentNotReadyError: If the equipment is not ready to accept commands. """ if self.bow_id is None or self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn off heater: bow_id or system_id is None") + msg = "Cannot turn off heater: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_heater_enable(self.bow_id, self.system_id, False) @control_method async def set_temperature(self, temperature: int) -> None: - """ - Sets the target temperature for the heater. + """Set the target temperature for the heater. Args: temperature: The target temperature to set in Fahrenheit. @@ -232,18 +228,19 @@ async def set_temperature(self, temperature: int) -> None: Fahrenheit before calling this method. """ if self.bow_id is None or self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot set heater temperature: bow_id or system_id is None") + msg = "Cannot set heater temperature: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) if temperature < self.min_temp or temperature > self.max_temp: - raise ValueError(f"Temperature {temperature}°F is outside valid range [{self.min_temp}°F, {self.max_temp}°F]") + msg = f"Temperature {temperature}°F is outside valid range [{self.min_temp}°F, {self.max_temp}°F]" + raise ValueError(msg) # Always use Fahrenheit as that's what the OmniLogic system uses internally await self._api.async_set_heater(self.bow_id, self.system_id, temperature) @control_method async def set_solar_temperature(self, temperature: int) -> None: - """ - Sets the solar heater set point. + """Set the solar heater set point. Args: temperature: The target solar temperature to set in Fahrenheit. @@ -261,10 +258,12 @@ async def set_solar_temperature(self, temperature: int) -> None: Fahrenheit before calling this method. """ if self.bow_id is None or self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot set solar heater temperature: bow_id or system_id is None") + msg = "Cannot set solar heater temperature: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) if temperature < self.min_temp or temperature > self.max_temp: - raise ValueError(f"Temperature {temperature}°F is outside valid range [{self.min_temp}°F, {self.max_temp}°F]") + msg = f"Temperature {temperature}°F is outside valid range [{self.min_temp}°F, {self.max_temp}°F]" + raise ValueError(msg) # Always use Fahrenheit as that's what the OmniLogic system uses internally await self._api.async_set_solar_heater(self.bow_id, self.system_id, temperature) diff --git a/pyomnilogic_local/heater_equip.py b/pyomnilogic_local/heater_equip.py index 69c692b..53a75a8 100644 --- a/pyomnilogic_local/heater_equip.py +++ b/pyomnilogic_local/heater_equip.py @@ -4,11 +4,13 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.models.mspconfig import MSPHeaterEquip -from pyomnilogic_local.models.telemetry import Telemetry, TelemetryHeater -from pyomnilogic_local.omnitypes import HeaterState, HeaterType +from pyomnilogic_local.models.telemetry import TelemetryHeater +from pyomnilogic_local.omnitypes import HeaterState if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import HeaterType class HeaterEquipment(OmniEquipment[MSPHeaterEquip, TelemetryHeater]): @@ -114,8 +116,7 @@ def state(self) -> HeaterState | int: @property def current_temp(self) -> int: - """ - Returns the current temperature reading from telemetry. + """Return the current temperature reading from telemetry. Note: Temperature is always in Fahrenheit internally. Use the system.units property to determine if conversion to Celsius is needed for display. diff --git a/pyomnilogic_local/models/__init__.py b/pyomnilogic_local/models/__init__.py index cd4ba09..320c5d4 100644 --- a/pyomnilogic_local/models/__init__.py +++ b/pyomnilogic_local/models/__init__.py @@ -1,3 +1,5 @@ +"""Pydantic models for the Hayward OmniLogic Local API.""" + from __future__ import annotations from .filter_diagnostics import FilterDiagnostics @@ -5,10 +7,10 @@ from .telemetry import Telemetry, TelemetryType __all__ = [ + "FilterDiagnostics", "MSPConfig", "MSPConfigType", "MSPEquipmentType", "Telemetry", "TelemetryType", - "FilterDiagnostics", ] diff --git a/pyomnilogic_local/models/exceptions.py b/pyomnilogic_local/models/exceptions.py index 82a90ae..bc486ec 100644 --- a/pyomnilogic_local/models/exceptions.py +++ b/pyomnilogic_local/models/exceptions.py @@ -1,2 +1,2 @@ -class OmniParsingException(Exception): +class OmniParsingError(Exception): pass diff --git a/pyomnilogic_local/models/filter_diagnostics.py b/pyomnilogic_local/models/filter_diagnostics.py index 3d4735e..939fa21 100644 --- a/pyomnilogic_local/models/filter_diagnostics.py +++ b/pyomnilogic_local/models/filter_diagnostics.py @@ -34,7 +34,7 @@ class FilterDiagnosticsParameter(BaseModel): model_config = ConfigDict(from_attributes=True) name: str = Field(alias="@name") - dataType: str = Field(alias="@dataType") + data_type: str = Field(alias="@dataType") value: int = Field(alias="#text") @@ -48,11 +48,10 @@ class FilterDiagnostics(BaseModel): model_config = ConfigDict(from_attributes=True) name: str = Field(alias="Name") - # parameters: FilterDiagnosticsParameters = Field(alias="Parameters") parameters: list[FilterDiagnosticsParameter] = Field(alias="Parameters") def get_param_by_name(self, name: str) -> int: - return [param.value for param in self.parameters if param.name == name][0] + return next(param.value for param in self.parameters if param.name == name) @staticmethod def load_xml(xml: str) -> FilterDiagnostics: diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index eab4f7a..b71fafe 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -1,13 +1,9 @@ +# ruff: noqa: TC001 # pydantic relies on the omnitypes imports at runtime from __future__ import annotations +import contextlib import logging -import sys -from typing import Any, ClassVar, Literal - -if sys.version_info >= (3, 11): - from typing import Self -else: - from typing_extensions import Self +from typing import Any, ClassVar, Literal, Self from pydantic import ( BaseModel, @@ -19,7 +15,7 @@ ) from xmltodict import parse as xml_parse -from ..omnitypes import ( +from pyomnilogic_local.omnitypes import ( BodyOfWaterType, ChlorinatorCellType, ChlorinatorDispenserType, @@ -46,7 +42,8 @@ SensorUnits, ZodiacShow, ) -from .exceptions import OmniParsingException + +from .exceptions import OmniParsingError _LOGGER = logging.getLogger(__name__) @@ -109,7 +106,6 @@ def convert_yes_no_to_bool(cls, data: Any) -> Any: class MSPSystem(BaseModel): model_config = ConfigDict(from_attributes=True) - # system_id: int = -1 # The System has no system-id, set it to -1 to signify this omni_type: OmniType = OmniType.SYSTEM @@ -234,11 +230,8 @@ def convert_cell_type(cls, data: Any) -> Any: cell_type_str = data["Cell-Type"] if isinstance(cell_type_str, str): # Parse by enum member name (e.g., "CELL_TYPE_T15" -> ChlorinatorCellType.CELL_TYPE_T15) - try: + with contextlib.suppress(KeyError): data["Cell-Type"] = ChlorinatorCellType[cell_type_str] - except KeyError: - # If not found, try to parse as int or leave as-is for Pydantic to handle - pass return data def __init__(self, **data: Any) -> None: @@ -392,11 +385,8 @@ def days_active(self) -> list[str]: >>> schedule.days_active ['Monday', 'Wednesday', 'Friday'] """ - flags = ScheduleDaysActive(self.days_active_raw) - final_flags = [flag.name for flag in ScheduleDaysActive if flags & flag and flag.name is not None] - - return final_flags + return [flag.name for flag in ScheduleDaysActive if flags & flag and flag.name is not None] type MSPEquipmentType = ( @@ -430,20 +420,24 @@ class MSPConfig(BaseModel): def __init__(self, **data: Any) -> None: # Extract groups from the Groups container if present - group_data: dict[Any, Any] | None = None - if (groups_data := data.get("Groups", None)) is not None: - group_data = groups_data.get("Group", None) + group_data: dict[str, Any] | None = None + with contextlib.suppress(KeyError): + group_data = data["Groups"]["Group"] - if group_data is not None: + if group_data: data["groups"] = [MSPGroup.model_validate(g) for g in group_data] + else: + data["groups"] = [] # Extract schedules from the Schedules container if present - schedule_data: dict[Any, Any] | None = None - if (schedules_data := data.get("Schedules", None)) is not None: - schedule_data = schedules_data.get("sche", None) + schedule_data: dict[str, Any] | None = None + with contextlib.suppress(KeyError): + schedule_data = data["Schedules"]["sche"] - if schedule_data is not None: + if schedule_data: data["schedules"] = [MSPSchedule.model_validate(s) for s in schedule_data] + else: + data["schedules"] = [] super().__init__(**data) @@ -469,4 +463,5 @@ def load_xml(xml: str) -> MSPConfig: try: return MSPConfig.model_validate(data["MSPConfig"], from_attributes=True) except ValidationError as exc: - raise OmniParsingException(f"Failed to parse MSP Configuration: {exc}") from exc + msg = f"Failed to parse MSP Configuration: {exc}" + raise OmniParsingError(msg) from exc diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index af3c631..78663e3 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -1,3 +1,4 @@ +# ruff: noqa: TC001 # pydantic relies on the omnitypes imports at runtime from __future__ import annotations from typing import Any, SupportsInt, cast, overload @@ -5,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, computed_field from xmltodict import parse as xml_parse -from ..omnitypes import ( +from pyomnilogic_local.omnitypes import ( BackyardState, ChlorinatorAlert, ChlorinatorError, @@ -36,7 +37,8 @@ ValveActuatorState, ZodiacShow, ) -from .exceptions import OmniParsingException + +from .exceptions import OmniParsingError # Example telemetry XML data: # @@ -160,7 +162,6 @@ def alerts(self) -> list[str]: >>> chlorinator.alerts ['SALT_LOW', 'HIGH_CURRENT'] """ - flags = ChlorinatorAlert(self.chlr_alert_raw) high_temp_bits = ChlorinatorAlert.CELL_TEMP_LOW | ChlorinatorAlert.CELL_TEMP_SCALEBACK cell_temp_high = False @@ -191,7 +192,6 @@ def errors(self) -> list[str]: >>> chlorinator.errors ['CURRENT_SENSOR_SHORT', 'VOLTAGE_SENSOR_OPEN'] """ - flags = ChlorinatorError(self.chlr_error_raw) cell_comm_loss_bits = ChlorinatorError.CELL_ERROR_TYPE | ChlorinatorError.CELL_ERROR_AUTH cell_comm_loss = False @@ -266,7 +266,7 @@ class TelemetryColorLogicLight(BaseModel): special_effect: int = Field(alias="@specialEffect") def show_name( - self, model: ColorLogicLightType, v2: bool, pretty: bool = False + self, model: ColorLogicLightType, v2: bool ) -> ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2 | PentairShow | ZodiacShow | int: """Get the current light show depending on the light type. @@ -512,7 +512,7 @@ def load_xml(xml: str) -> Telemetry: def xml_postprocessor(path: Any, key: Any, value: SupportsInt) -> tuple[Any, SupportsInt]: ... @overload def xml_postprocessor(path: Any, key: Any, value: Any) -> tuple[Any, Any]: ... - def xml_postprocessor(path: Any, key: Any, value: SupportsInt | Any) -> tuple[Any, SupportsInt | Any]: + def xml_postprocessor(path: Any, key: Any, value: SupportsInt | Any) -> tuple[Any, SupportsInt | Any]: # noqa: ARG001 # Unused argument is part of the xmltodict postprocessor signature """Post process XML to attempt to convert values to int. Pydantic can coerce values natively, but the Omni API returns values as strings of numbers (I.E. "2", "5", etc) and we need them @@ -553,7 +553,8 @@ def xml_postprocessor(path: Any, key: Any, value: SupportsInt | Any) -> tuple[An try: return Telemetry.model_validate(data["STATUS"]) except ValidationError as exc: - raise OmniParsingException(f"Failed to parse Telemetry: {exc}") from exc + msg = f"Failed to parse Telemetry: {exc}" + raise OmniParsingError(msg) from exc def get_telem_by_systemid(self, system_id: int) -> TelemetryType | None: for field_name, value in self: @@ -561,11 +562,11 @@ def get_telem_by_systemid(self, system_id: int) -> TelemetryType | None: continue if isinstance(value, list): for model in value: - cast_model = cast(TelemetryType, model) + cast_model = cast("TelemetryType", model) if cast_model.system_id == system_id: return cast_model else: - cast_model = cast(TelemetryType, value) + cast_model = cast("TelemetryType", value) if cast_model.system_id == system_id: return cast_model return None diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py index 05e2869..cb889e9 100644 --- a/pyomnilogic_local/omnilogic.py +++ b/pyomnilogic_local/omnilogic.py @@ -3,28 +3,31 @@ import asyncio import logging import time -from typing import Any +from typing import TYPE_CHECKING, Any -from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.api import OmniLogicAPI from pyomnilogic_local.backyard import Backyard -from pyomnilogic_local.chlorinator import Chlorinator -from pyomnilogic_local.chlorinator_equip import ChlorinatorEquipment from pyomnilogic_local.collections import EquipmentDict -from pyomnilogic_local.colorlogiclight import ColorLogicLight -from pyomnilogic_local.csad import CSAD -from pyomnilogic_local.csad_equip import CSADEquipment -from pyomnilogic_local.filter import Filter from pyomnilogic_local.groups import Group -from pyomnilogic_local.heater import Heater -from pyomnilogic_local.heater_equip import HeaterEquipment -from pyomnilogic_local.models import MSPConfig, Telemetry -from pyomnilogic_local.pump import Pump -from pyomnilogic_local.relay import Relay from pyomnilogic_local.schedule import Schedule -from pyomnilogic_local.sensor import Sensor from pyomnilogic_local.system import System +if TYPE_CHECKING: + from pyomnilogic_local._base import OmniEquipment + from pyomnilogic_local.chlorinator import Chlorinator + from pyomnilogic_local.chlorinator_equip import ChlorinatorEquipment + from pyomnilogic_local.colorlogiclight import ColorLogicLight + from pyomnilogic_local.csad import CSAD + from pyomnilogic_local.csad_equip import CSADEquipment + from pyomnilogic_local.filter import Filter + from pyomnilogic_local.heater import Heater + from pyomnilogic_local.heater_equip import HeaterEquipment + from pyomnilogic_local.models import MSPConfig, Telemetry + from pyomnilogic_local.pump import Pump + from pyomnilogic_local.relay import Relay + from pyomnilogic_local.sensor import Sensor + + _LOGGER = logging.getLogger(__name__) @@ -96,11 +99,7 @@ async def refresh( # Determine if telemetry needs updating update_telemetry = False - if force: - update_telemetry = True - elif if_dirty and self._telemetry_dirty: - update_telemetry = True - elif (current_time - self._telemetry_last_updated) > if_older_than: + if force or (if_dirty and self._telemetry_dirty) or ((current_time - self._telemetry_last_updated) > if_older_than): update_telemetry = True # Update telemetry if needed @@ -116,15 +115,18 @@ async def refresh( if self.telemetry.backyard.config_checksum != self._mspconfig_checksum: update_mspconfig = True - if self.telemetry.backyard.msp_version is not None: - if not self._warned_mspversion and not self.telemetry.backyard.msp_version.startswith(self._min_mspversion): - _LOGGER.warning( - "Detected OmniLogic MSP version %s, which is below the minimum supported version %s. " - "Some features may not work correctly. Please consider updating your OmniLogic controller firmware.", - self.telemetry.backyard.msp_version, - self._min_mspversion, - ) - self._warned_mspversion = True + if ( + self.telemetry.backyard.msp_version is not None + and not self._warned_mspversion + and not self.telemetry.backyard.msp_version.startswith(self._min_mspversion) + ): + _LOGGER.warning( + "Detected OmniLogic MSP version %s, which is below the minimum supported version %s. " + "Some features may not work correctly. Please consider updating your OmniLogic controller firmware.", + self.telemetry.backyard.msp_version, + self._min_mspversion, + ) + self._warned_mspversion = True # Update MSPConfig if needed if update_mspconfig: @@ -137,7 +139,6 @@ async def refresh( def _update_equipment(self) -> None: """Update equipment objects based on the latest MSPConfig and Telemetry data.""" - if not hasattr(self, "mspconfig") or self.mspconfig is None: _LOGGER.debug("No MSPConfig data available; skipping equipment update") return @@ -217,10 +218,7 @@ def all_sensors(self) -> EquipmentDict[Sensor]: @property def all_heaters(self) -> EquipmentDict[Heater]: """Returns all Heater (VirtualHeater) instances across all bows in the backyard.""" - heaters: list[Heater] = [] - for bow in self.backyard.bow.values(): - if bow.heater is not None: - heaters.append(bow.heater) + heaters = [bow.heater for bow in self.backyard.bow.values() if bow.heater is not None] return EquipmentDict(heaters) @property @@ -234,10 +232,7 @@ def all_heater_equipment(self) -> EquipmentDict[HeaterEquipment]: @property def all_chlorinators(self) -> EquipmentDict[Chlorinator]: """Returns all Chlorinator instances across all bows in the backyard.""" - chlorinators: list[Chlorinator] = [] - for bow in self.backyard.bow.values(): - if bow.chlorinator is not None: - chlorinators.append(bow.chlorinator) + chlorinators = [bow.chlorinator for bow in self.backyard.bow.values() if bow.chlorinator is not None] return EquipmentDict(chlorinators) @property @@ -266,8 +261,7 @@ def all_csads(self) -> EquipmentDict[CSAD]: # Equipment search methods def get_equipment_by_name(self, name: str) -> OmniEquipment[Any, Any] | None: - """ - Find equipment by name across all equipment types. + """Find equipment by name across all equipment types. Args: name: The name of the equipment to find @@ -297,8 +291,7 @@ def get_equipment_by_name(self, name: str) -> OmniEquipment[Any, Any] | None: return None def get_equipment_by_id(self, system_id: int) -> OmniEquipment[Any, Any] | None: - """ - Find equipment by system_id across all equipment types. + """Find equipment by system_id across all equipment types. Args: system_id: The system ID of the equipment to find diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index cd72072..26b2296 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -334,6 +334,7 @@ class ColorLogicLightType(StrEnum, PrettyEnum): ZODIAC_COLOR = "CL_Z_COLOR" def __str__(self) -> str: + """Return the string representation of the ColorLogicLightType.""" return ColorLogicLightType[self.name].value diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index 6c75309..1c47c49 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -263,8 +263,8 @@ async def set_speed(self, speed: int) -> None: msg = "Pump bow_id and system_id must be set" raise OmniEquipmentNotInitializedError(msg) - if not 0 <= speed <= 100: - msg = f"Speed {speed} is outside valid range [0, 100]" + if not self.min_percent <= speed <= self.max_percent: + msg = f"Speed {speed} is outside valid range [{self.min_percent}, {self.max_percent}]" raise ValueError(msg) # Note: The API validates against min_percent/max_percent internally diff --git a/pyomnilogic_local/relay.py b/pyomnilogic_local/relay.py index e2f64a7..dd3a4de 100644 --- a/pyomnilogic_local/relay.py +++ b/pyomnilogic_local/relay.py @@ -5,12 +5,14 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPRelay -from pyomnilogic_local.models.telemetry import Telemetry, TelemetryRelay -from pyomnilogic_local.omnitypes import RelayFunction, RelayState, RelayType, RelayWhyOn +from pyomnilogic_local.models.telemetry import TelemetryRelay +from pyomnilogic_local.omnitypes import RelayState from pyomnilogic_local.util import OmniEquipmentNotInitializedError if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import RelayFunction, RelayType, RelayWhyOn class Relay(OmniEquipment[MSPRelay, TelemetryRelay]): @@ -103,24 +105,24 @@ def is_on(self) -> bool: @control_method async def turn_on(self) -> None: - """ - Turns the relay on. + """Turn on the relay. Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. """ if self.bow_id is None or self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn on relay: bow_id or system_id is None") + msg = "Cannot turn on relay: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_equipment(self.bow_id, self.system_id, True) @control_method async def turn_off(self) -> None: - """ - Turns the relay off. + """Turn off the relay. Raises: OmniEquipmentNotInitializedError: If bow_id or system_id is None. """ if self.bow_id is None or self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn off relay: bow_id or system_id is None") + msg = "Cannot turn off relay: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_set_equipment(self.bow_id, self.system_id, False) diff --git a/pyomnilogic_local/schedule.py b/pyomnilogic_local/schedule.py index 8e8473e..347ba89 100644 --- a/pyomnilogic_local/schedule.py +++ b/pyomnilogic_local/schedule.py @@ -5,10 +5,10 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.decorators import control_method from pyomnilogic_local.models.mspconfig import MSPSchedule -from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.util import OmniEquipmentNotInitializedError if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic @@ -147,8 +147,7 @@ def controlled_equipment(self) -> OmniEquipment[Any, Any] | None: @control_method async def turn_on(self) -> None: - """ - Enable the schedule. + """Enable the schedule. Sends an edit command with all current schedule parameters but sets the enabled state to True. @@ -157,7 +156,8 @@ async def turn_on(self) -> None: OmniEquipmentNotInitializedError: If system_id is None. """ if self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn on schedule: system_id is None") + msg = "Cannot turn on schedule: system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_edit_schedule( equipment_id=self.system_id, # This is the schedule-system-id @@ -174,8 +174,7 @@ async def turn_on(self) -> None: @control_method async def turn_off(self) -> None: - """ - Disable the schedule. + """Disable the schedule. Sends an edit command with all current schedule parameters but sets the enabled state to False. @@ -184,7 +183,8 @@ async def turn_off(self) -> None: OmniEquipmentNotInitializedError: If system_id is None. """ if self.system_id is None: - raise OmniEquipmentNotInitializedError("Cannot turn off schedule: system_id is None") + msg = "Cannot turn off schedule: system_id is None" + raise OmniEquipmentNotInitializedError(msg) await self._api.async_edit_schedule( equipment_id=self.system_id, # This is the schedule-system-id diff --git a/pyomnilogic_local/sensor.py b/pyomnilogic_local/sensor.py index 9424de4..deab9c4 100644 --- a/pyomnilogic_local/sensor.py +++ b/pyomnilogic_local/sensor.py @@ -4,11 +4,11 @@ from pyomnilogic_local._base import OmniEquipment from pyomnilogic_local.models.mspconfig import MSPSensor -from pyomnilogic_local.models.telemetry import Telemetry -from pyomnilogic_local.omnitypes import SensorType, SensorUnits if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import SensorType, SensorUnits class Sensor(OmniEquipment[MSPSensor, None]): @@ -87,8 +87,7 @@ def __init__(self, omni: OmniLogic, mspconfig: MSPSensor, telemetry: Telemetry | @property def sensor_type(self) -> SensorType | str: - """ - Returns the type of sensor. + """Returns the type of sensor. Can be AIR_TEMP, SOLAR_TEMP, WATER_TEMP, FLOW, ORP, or EXT_INPUT. """ @@ -96,8 +95,7 @@ def sensor_type(self) -> SensorType | str: @property def units(self) -> SensorUnits | str: - """ - Returns the units used by the sensor. + """Returns the units used by the sensor. Can be FAHRENHEIT, CELSIUS, PPM, GRAMS_PER_LITER, MILLIVOLTS, NO_UNITS, or ACTIVE_INACTIVE. diff --git a/pyomnilogic_local/system.py b/pyomnilogic_local/system.py index 707dc90..5c63918 100644 --- a/pyomnilogic_local/system.py +++ b/pyomnilogic_local/system.py @@ -1,6 +1,9 @@ from __future__ import annotations -from pyomnilogic_local.models.mspconfig import MSPSystem +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPSystem class System: @@ -9,8 +12,6 @@ class System: mspconfig: MSPSystem def __init__(self, mspconfig: MSPSystem) -> None: - # self.vsp_speed_format = mspconfig.vsp_speed_format - # self.units = mspconfig.units self.update_config(mspconfig) @property diff --git a/pyomnilogic_local/util.py b/pyomnilogic_local/util.py index 26f8e93..0bcc49f 100644 --- a/pyomnilogic_local/util.py +++ b/pyomnilogic_local/util.py @@ -1,12 +1,7 @@ from __future__ import annotations -import sys from enum import Enum - -if sys.version_info >= (3, 11): - from typing import Self -else: - from typing_extensions import Self +from typing import Self class OmniLogicLocalError(Exception): diff --git a/pyproject.toml b/pyproject.toml index 5fa2ad2..42b8cb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,14 +2,14 @@ name = "python-omnilogic-local" version = "0.19.0" description = "A library for local control of Hayward OmniHub/OmniLogic pool controllers using their local API" +readme = "README.md" +requires-python = ">=3.13,<4.0.0" authors = [ {name = "Chris Jowett",email = "421501+cryptk@users.noreply.github.com"}, {name = "djtimca"}, {name = "garionphx"} ] license = {text = "Apache-2.0"} -readme = "README.md" -requires-python = ">=3.12,<4.0.0" dependencies = [ "pydantic >=2.0.0,<3.0.0", "click >=8.0.0,<8.4.0", @@ -24,20 +24,6 @@ cli = [ "scapy>=2.6.1,<3.0.0", ] -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["pyomnilogic_local"] - -[tool.black] -line-length=140 - -[tool.isort] -# https://github.com/PyCQA/isort/wiki/isort-Settings -profile = "black" - [tool.mypy] python_version = "3.13" plugins = [ @@ -50,100 +36,56 @@ ignore_missing_imports = true disallow_subclassing_any = false warn_return_any = false -[tool.pydantic-mypy] -init_forbid_extra = true -init_typed = true -warn_required_dynamic_aliases = true -warn_untyped_fields = true - -[tool.pylint.MAIN] -py-version = "3.13" -extension-pkg-allow-list = [ - "pydantic", -] -ignore = [ - "tests", -] -# Use a conservative default here; 2 should speed up most setups and not hurt -# any too bad. Override on command line as appropriate. -jobs = 2 -load-plugins = [ - "pylint.extensions.code_style", - "pylint.extensions.typing", -] - [tool.pylint."FORMAT"] expected-line-ending-format = "LF" # Maximum number of characters on a single line. max-line-length=140 -[tool.pylint."MESSAGES CONTROL"] -# Reasons disabled: -# format - handled by black -# locally-disabled - it spams too much -# duplicate-code - unavoidable -# cyclic-import - doesn't test if both import on load -# abstract-class-little-used - prevents from setting right foundation -# unused-argument - generic callbacks and setup methods create a lot of warnings -# too-many-* - are not enforced for the sake of readability -# too-few-* - same as too-many-* -# abstract-method - with intro of async there are always methods missing -# inconsistent-return-statements - doesn't handle raise -# too-many-ancestors - it's too strict. -# wrong-import-order - isort guards this -# consider-using-f-string - str.format sometimes more readable -# --- -# Pylint CodeStyle plugin -# consider-using-namedtuple-or-dataclass - too opinionated -# consider-using-assignment-expr - decision to use := better left to devs -disable = [ - "format", - # "abstract-method", - # "cyclic-import", - "duplicate-code", - # "inconsistent-return-statements", - "locally-disabled", - # "not-context-manager", - # "too-few-public-methods", - # "too-many-ancestors", - "too-many-arguments", - "too-many-branches", - "too-many-instance-attributes", - # "too-many-lines", - "too-many-locals", - "too-many-public-methods", - "too-many-return-statements", - # "too-many-statements", - # "too-many-boolean-expressions", - "unused-argument", - "wrong-import-order", - "wrong-import-position", - # "consider-using-f-string", - # The below are only here for now, we should fully document once the codebase stops fluctuating so much - "missing-class-docstring", - "missing-function-docstring", - "missing-module-docstring", -] -enable = [ - "useless-suppression", - "use-symbolic-message-instead", -] - [tool.ruff] line-length = 140 -[tool.semantic_release] -branch = "main" -version_toml = "pyproject.toml:project.version" -build_command = "pip install hatch && hatch build" - -[dependency-groups] -dev = [ - "pre-commit>=4.0.0,<5.0.0", - "mypy>=1.18.2,<2.0.0", - "pylint>=4.0.0,<5.0.0", - "pytest>=8.0.0,<9.0.0", - "pytest-cov>=7.0.0,<8.0.0", - "pytest-asyncio>=1.2.0,<2.0.0", - "pytest-subtests>=0.15.0,<1.0.0", +[tool.ruff.lint] +select = [ + "ERA", # eradicate commented code + "ASYNC", # flake8-async + "B", # flake8-bugbear + "A", # flake8-builtins + "EM", # flake8-errmsg + "FIX", # flake8-fixme + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "G", # flake8-logging-format + "INP", # flake8-no-pep420 + "PIE", # flake8-pie + "T20", # flake8-print + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "SIM", # flake8-simplify + "PTH", # flake8-use-pathlib + "TID252", # flake8-tidy-imports + "TD", # flake8-todos + "TC", # flake8-type-checking + "ARG", # flake8-unused-arguments + "I", # isort + "N", # pep8-naming + "PERF", # perflint + "E", # pycodestyle errors + "W", # pycodestyle warnings + # "DOC", # pydoclint, only available with ruff preview mode enabled + "D", # pydocstyle + "F", # pyflakes + "UP", # pyupgrade + "RUF", # ruff-specific rules + "TRY", +] +ignore = [ + "D100", # Disabled until we get everything documented. + "D101", # Disabled until we get everything documented. + "D102", # Disabled until we get everything documented. + "D107", # Disabled until we get everything documented. ] +future-annotations = true + +[tool.ruff.lint.pydocstyle] +convention = "google" diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..38d770e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Hayward OmniLogic Local package.""" diff --git a/tests/test_api.py b/tests/test_api.py index e970122..d1b99a6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,8 +1,6 @@ -# pylint: skip-file # type: ignore -""" -Comprehensive tests for the OmniLogic API layer. +"""Comprehensive tests for the OmniLogic API layer. Focuses on: - Validation function tests (table-driven) @@ -11,32 +9,21 @@ - Transport/protocol integration tests """ +from __future__ import annotations + +from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch from xml.etree import ElementTree as ET import pytest -from pytest_subtests import SubTests - -from pyomnilogic_local.api.api import ( - OmniLogicAPI, - _validate_id, - _validate_speed, - _validate_temperature, -) -from pyomnilogic_local.api.constants import ( - MAX_SPEED_PERCENT, - MAX_TEMPERATURE_F, - MIN_SPEED_PERCENT, - MIN_TEMPERATURE_F, - XML_NAMESPACE, -) -from pyomnilogic_local.api.exceptions import OmniValidationException -from pyomnilogic_local.omnitypes import ( - ColorLogicBrightness, - ColorLogicShow40, - ColorLogicSpeed, - HeaterMode, -) + +from pyomnilogic_local.api.api import OmniLogicAPI, _validate_id, _validate_speed, _validate_temperature +from pyomnilogic_local.api.constants import MAX_SPEED_PERCENT, MAX_TEMPERATURE_F, MIN_SPEED_PERCENT, MIN_TEMPERATURE_F, XML_NAMESPACE +from pyomnilogic_local.api.exceptions import OmniValidationError +from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicShow40, ColorLogicSpeed, HeaterMode + +if TYPE_CHECKING: + from pytest_subtests import SubTests # ============================================================================ # Helper Functions @@ -52,7 +39,8 @@ def _find_elem(root: ET.Element, path: str) -> ET.Element: """Find element with namespace support, raising if not found.""" elem = root.find(f".//{{{XML_NAMESPACE}}}{path}") if elem is None: - raise AssertionError(f"Element {path} not found in XML") + msg = f"Element {path} not found in XML" + raise AssertionError(msg) return elem @@ -60,7 +48,8 @@ def _find_param(root: ET.Element, name: str) -> ET.Element: """Find parameter by name attribute.""" elem = root.find(f".//{{{XML_NAMESPACE}}}Parameter[@name='{name}']") if elem is None: - raise AssertionError(f"Parameter {name} not found in XML") + msg = f"Parameter {name} not found in XML" + raise AssertionError(msg) return elem @@ -72,7 +61,7 @@ def _find_param(root: ET.Element, name: str) -> ET.Element: def test_validate_temperature(subtests: SubTests) -> None: """Test temperature validation with various inputs using table-driven approach.""" test_cases = [ - # (temperature, param_name, should_pass, description) + # (temperature, param_name, should_pass, description) # noqa: ERA001 (MIN_TEMPERATURE_F, "temp", True, "minimum valid temperature"), (MAX_TEMPERATURE_F, "temp", True, "maximum valid temperature"), (80, "temp", True, "mid-range valid temperature"), @@ -88,14 +77,14 @@ def test_validate_temperature(subtests: SubTests) -> None: if should_pass: _validate_temperature(temperature, param_name) # Should not raise else: - with pytest.raises(OmniValidationException): + with pytest.raises(OmniValidationError): _validate_temperature(temperature, param_name) def test_validate_speed(subtests: SubTests) -> None: """Test speed validation with various inputs using table-driven approach.""" test_cases = [ - # (speed, param_name, should_pass, description) + # (speed, param_name, should_pass, description) # noqa: ERA001 (MIN_SPEED_PERCENT, "speed", True, "minimum valid speed (0)"), (MAX_SPEED_PERCENT, "speed", True, "maximum valid speed (100)"), (50, "speed", True, "mid-range valid speed"), @@ -111,14 +100,14 @@ def test_validate_speed(subtests: SubTests) -> None: if should_pass: _validate_speed(speed, param_name) # Should not raise else: - with pytest.raises(OmniValidationException): + with pytest.raises(OmniValidationError): _validate_speed(speed, param_name) def test_validate_id(subtests: SubTests) -> None: """Test ID validation with various inputs using table-driven approach.""" test_cases = [ - # (id_value, param_name, should_pass, description) + # (id_value, param_name, should_pass, description) # noqa: ERA001 (0, "pool_id", True, "zero ID"), (1, "pool_id", True, "positive ID"), (999999, "pool_id", True, "large positive ID"), @@ -133,7 +122,7 @@ def test_validate_id(subtests: SubTests) -> None: if should_pass: _validate_id(id_value, param_name) # Should not raise else: - with pytest.raises(OmniValidationException): + with pytest.raises(OmniValidationError): _validate_id(id_value, param_name) @@ -161,7 +150,7 @@ def test_api_init_custom_params() -> None: def test_api_init_validation(subtests: SubTests) -> None: """Test OmniLogicAPI initialization validation using table-driven approach.""" test_cases = [ - # (ip, port, timeout, should_pass, description) + # (ip, port, timeout, should_pass, description) # noqa: ERA001 ("", 10444, 5.0, False, "empty IP address"), ("192.168.1.100", 0, 5.0, False, "zero port"), ("192.168.1.100", -1, 5.0, False, "negative port"), @@ -178,7 +167,7 @@ def test_api_init_validation(subtests: SubTests) -> None: api = OmniLogicAPI(ip, port, timeout) assert api is not None else: - with pytest.raises(OmniValidationException): + with pytest.raises(OmniValidationError): OmniLogicAPI(ip, port, timeout) @@ -405,17 +394,16 @@ async def test_async_set_chlorinator_enable_boolean_conversion(subtests: SubTest ] for enabled, expected, description in test_cases: - with subtests.test(msg=description): - with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: - mock_send.return_value = None + with subtests.test(msg=description), patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None - await api.async_set_chlorinator_enable(pool_id=1, enabled=enabled) + await api.async_set_chlorinator_enable(pool_id=1, enabled=enabled) - call_args = mock_send.call_args - xml_payload = call_args[0][1] - root = ET.fromstring(xml_payload) + call_args = mock_send.call_args + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) - assert _find_param(root, "Enabled").text == expected + assert _find_param(root, "Enabled").text == expected @pytest.mark.asyncio @@ -431,17 +419,16 @@ async def test_async_set_heater_enable_boolean_conversion(subtests: SubTests) -> ] for enabled, expected, description in test_cases: - with subtests.test(msg=description): - with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: - mock_send.return_value = None + with subtests.test(msg=description), patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None - await api.async_set_heater_enable(pool_id=1, equipment_id=2, enabled=enabled) + await api.async_set_heater_enable(pool_id=1, equipment_id=2, enabled=enabled) - call_args = mock_send.call_args - xml_payload = call_args[0][1] - root = ET.fromstring(xml_payload) + call_args = mock_send.call_args + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) - assert _find_param(root, "Enabled").text == expected + assert _find_param(root, "Enabled").text == expected @pytest.mark.asyncio diff --git a/tests/test_chlorinator_bitmask.py b/tests/test_chlorinator_bitmask.py index 7bf4425..b802903 100644 --- a/tests/test_chlorinator_bitmask.py +++ b/tests/test_chlorinator_bitmask.py @@ -1,6 +1,6 @@ """Tests for chlorinator bitmask decoding.""" -# pylint: skip-file +from __future__ import annotations from pyomnilogic_local.models.telemetry import TelemetryChlorinator @@ -11,7 +11,7 @@ def test_chlorinator_status_decoding() -> None: # Bit 1: ALERT_PRESENT (2) # Bit 2: GENERATING (4) # Bit 7: K2_ACTIVE (128) - # Total: 2 + 4 + 128 = 134 + # Total: 2 + 4 + 128 = 134 # noqa: ERA001 data = { "@systemId": 5, "@status": 134, @@ -75,7 +75,7 @@ def test_chlorinator_error_decoding() -> None: # Create a chlorinator with chlrError = 257 (0b100000001) # Bit 0: CURRENT_SENSOR_SHORT (1) # Bit 8: K1_RELAY_SHORT (256) - # Total: 1 + 256 = 257 + # Total: 1 + 256 = 257 # noqa: ERA001 data = { "@systemId": 5, "@status": 1, # ERROR_PRESENT @@ -127,11 +127,11 @@ def test_chlorinator_no_flags() -> None: def test_chlorinator_complex_alerts() -> None: """Test complex multi-bit alert combinations.""" - # chlrAlert = 67 (0b01000011) + # chlrAlert = 67 (0b01000011) # noqa: ERA001 # Bit 0: SALT_LOW (1) # Bit 1: SALT_VERY_LOW (2) # Bit 6: BOARD_TEMP_HIGH (64) - # Total: 1 + 2 + 64 = 67 + # Total: 1 + 2 + 64 = 67 # noqa: ERA001 data = { "@systemId": 5, "@status": 2, diff --git a/tests/test_chlorinator_multibit.py b/tests/test_chlorinator_multibit.py index 81d2453..92435df 100644 --- a/tests/test_chlorinator_multibit.py +++ b/tests/test_chlorinator_multibit.py @@ -1,6 +1,6 @@ """Tests for chlorinator multi-bit field special case handling.""" -# pylint: skip-file +from __future__ import annotations from pyomnilogic_local.models.telemetry import TelemetryChlorinator diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 2c3149a..3fdf4f3 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,8 +1,5 @@ -# pylint: skip-file # type: ignore - -""" -Tests for equipment control method decorators. +"""Tests for equipment control method decorators. Focuses on: - @control_method decorator behavior @@ -11,6 +8,8 @@ - Error message generation """ +from __future__ import annotations + from unittest.mock import MagicMock import pytest @@ -173,6 +172,7 @@ async def test_control_method_preserves_function_metadata(): assert equipment.turn_on.__name__ == "turn_on" assert equipment.turn_on.__doc__ == "Mock turn_on method." assert equipment.set_temperature.__name__ == "set_temperature" + assert equipment.set_temperature.__doc__ is not None assert "args" in equipment.set_temperature.__doc__ diff --git a/tests/test_effects_collection.py b/tests/test_effects_collection.py index f583371..2b73e22 100644 --- a/tests/test_effects_collection.py +++ b/tests/test_effects_collection.py @@ -1,13 +1,16 @@ """Tests for the EffectsCollection class.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + import pytest -from pyomnilogic_local.collections import EffectsCollection, LightEffectsCollection -from pyomnilogic_local.omnitypes import ( - ColorLogicShow25, - ColorLogicShow40, - ColorLogicShowUCL, -) +from pyomnilogic_local.collections import EffectsCollection +from pyomnilogic_local.omnitypes import ColorLogicShow25, ColorLogicShow40, ColorLogicShowUCL + +if TYPE_CHECKING: + from pyomnilogic_local.collections import LightEffectsCollection class TestEffectsCollection: @@ -106,13 +109,13 @@ def test_iteration(self) -> None: def test_length(self) -> None: """Test that len() works correctly.""" - effects25 = EffectsCollection(list(ColorLogicShow25)) - effects40 = EffectsCollection(list(ColorLogicShow40)) - effectsUCL = EffectsCollection(list(ColorLogicShowUCL)) # pylint: disable=invalid-name + effects_25 = EffectsCollection(list(ColorLogicShow25)) + effects_40 = EffectsCollection(list(ColorLogicShow40)) + effects_ucl = EffectsCollection(list(ColorLogicShowUCL)) - assert len(effects25) == len(ColorLogicShow25) - assert len(effects40) == len(ColorLogicShow40) - assert len(effectsUCL) == len(ColorLogicShowUCL) + assert len(effects_25) == len(ColorLogicShow25) + assert len(effects_40) == len(ColorLogicShow40) + assert len(effects_ucl) == len(ColorLogicShowUCL) def test_repr(self) -> None: """Test string representation.""" @@ -142,17 +145,17 @@ def test_type_alias(self) -> None: def test_different_show_types(self) -> None: """Test that different show types are correctly distinguished.""" - effects25 = EffectsCollection(list(ColorLogicShow25)) - effectsUCL = EffectsCollection(list(ColorLogicShowUCL)) # pylint: disable=invalid-name + effects_25 = EffectsCollection(list(ColorLogicShow25)) + effects_ucl = EffectsCollection(list(ColorLogicShowUCL)) # UCL has ROYAL_BLUE, 2.5 doesn't - assert "ROYAL_BLUE" not in effects25 - assert "ROYAL_BLUE" in effectsUCL + assert "ROYAL_BLUE" not in effects_25 + assert "ROYAL_BLUE" in effects_ucl # Both have VOODOO_LOUNGE and they're from different enums - assert effects25.VOODOO_LOUNGE is ColorLogicShow25.VOODOO_LOUNGE - assert effectsUCL.VOODOO_LOUNGE is ColorLogicShowUCL.VOODOO_LOUNGE + assert effects_25.VOODOO_LOUNGE is ColorLogicShow25.VOODOO_LOUNGE + assert effects_ucl.VOODOO_LOUNGE is ColorLogicShowUCL.VOODOO_LOUNGE # Even though they have the same value (0), they're different enum types - assert type(effects25.VOODOO_LOUNGE) is not type(effectsUCL.VOODOO_LOUNGE) # type: ignore - assert isinstance(effects25.VOODOO_LOUNGE, ColorLogicShow25) - assert isinstance(effectsUCL.VOODOO_LOUNGE, ColorLogicShowUCL) + assert type(effects_25.VOODOO_LOUNGE) is not type(effects_ucl.VOODOO_LOUNGE) # type: ignore + assert isinstance(effects_25.VOODOO_LOUNGE, ColorLogicShow25) + assert isinstance(effects_ucl.VOODOO_LOUNGE, ColorLogicShowUCL) diff --git a/tests/test_filter_pump.py b/tests/test_filter_pump.py index 230dd25..fb6d608 100644 --- a/tests/test_filter_pump.py +++ b/tests/test_filter_pump.py @@ -1,9 +1,7 @@ +# type: ignore """Tests for Filter and Pump equipment classes.""" -# pyright: basic -# mypy: ignore-errors -# type: ignore -# pylint: skip-file +from __future__ import annotations from unittest.mock import AsyncMock, Mock @@ -56,7 +54,7 @@ def sample_filter_telemetry(): omni_type=OmniType.FILTER, **{ "@systemId": 8, - "@filterState": 1, + "@filterState": FilterState.ON, "@filterSpeed": 60, "@valvePosition": 1, "@whyFilterIsOn": 14, @@ -95,7 +93,7 @@ def sample_pump_telemetry(): omni_type=OmniType.PUMP, **{ "@systemId": 15, - "@pumpState": 1, + "@pumpState": PumpState.ON, "@pumpSpeed": 60, "@lastSpeed": 50, "@whyOn": 11, diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 6462771..b4f41eb 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -13,15 +13,19 @@ import json import pathlib -from typing import Any +from typing import TYPE_CHECKING, Any import pytest -from pytest_subtests import SubTests from pyomnilogic_local.models.mspconfig import MSPConfig from pyomnilogic_local.models.telemetry import Telemetry from pyomnilogic_local.omnitypes import OmniType +if TYPE_CHECKING: + from pytest_subtests import SubTests + + from pyomnilogic_local._base import OmniEquipment + # Path to fixtures directory FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" @@ -36,9 +40,8 @@ def load_fixture(filename: str) -> dict[str, str]: Dictionary with 'mspconfig' and 'telemetry' keys containing XML strings """ fixture_path = FIXTURES_DIR / filename - with open(fixture_path, encoding="utf-8") as f: - data = json.load(f) - return data + with fixture_path.open(encoding="utf-8") as f: + return json.load(f) def get_equipment_by_type(msp: MSPConfig, omni_type: OmniType) -> list[Any]: @@ -51,16 +54,14 @@ def get_equipment_by_type(msp: MSPConfig, omni_type: OmniType) -> list[Any]: Returns: List of equipment matching the type """ - equipment = [] + equipment: list[OmniEquipment[Any, Any]] = [] # Check backyard-level equipment for attr_name in ("relay", "sensor", "colorlogic_light"): if items := getattr(msp.backyard, attr_name, None): - for item in items: - if item.omni_type == omni_type: - equipment.append(item) + equipment.extend(item for item in items if item.omni_type == omni_type) # Check BoW-level equipment - if msp.backyard.bow: # pylint: disable=too-many-nested-blocks + if msp.backyard.bow: for bow in msp.backyard.bow: for attr_name in ("filter", "heater", "pump", "relay", "sensor", "colorlogic_light", "chlorinator"): if items := getattr(bow, attr_name, None): @@ -71,9 +72,7 @@ def get_equipment_by_type(msp: MSPConfig, omni_type: OmniType) -> list[Any]: equipment.append(item) # Check child equipment (e.g., heater equipment within virtual heater) if hasattr(item, "heater_equipment") and item.heater_equipment: - for child in item.heater_equipment: - if child.omni_type == omni_type: - equipment.append(child) + equipment.extend(child for child in item.heater_equipment if child.omni_type == omni_type) return equipment @@ -355,18 +354,18 @@ def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> No assert bow_ids == [1, 8] with subtests.test(msg="pool water temp"): - pool_bow = [bow for bow in telem.bow if bow.system_id == 1][0] + pool_bow = next(bow for bow in telem.bow if bow.system_id == 1) assert pool_bow.water_temp == 74 with subtests.test(msg="spa water temp"): - spa_bow = [bow for bow in telem.bow if bow.system_id == 8][0] + spa_bow = next(bow for bow in telem.bow if bow.system_id == 8) assert spa_bow.water_temp == -1 # No valid reading with subtests.test(msg="filter telemetry"): assert telem.filter is not None assert len(telem.filter) == 2 # Pool filter running - pool_filter = [f for f in telem.filter if f.system_id == 3][0] + pool_filter = next(f for f in telem.filter if f.system_id == 3) assert pool_filter.speed == 31 assert pool_filter.power == 79 @@ -380,7 +379,7 @@ def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> No assert telem.relay is not None assert len(telem.relay) == 3 # Check yard lights relay is on - yard_relay = [r for r in telem.relay if r.system_id == 27][0] + yard_relay = next(r for r in telem.relay if r.system_id == 27) assert yard_relay.state.value == 1 # ON with subtests.test(msg="pump telemetry"): @@ -419,13 +418,13 @@ def test_fixture_parses_without_error(fixture_file: str) -> None: data = load_fixture(fixture_file) # Parse MSPConfig - if "mspconfig" in data and data["mspconfig"]: + if data.get("mspconfig"): msp = MSPConfig.load_xml(data["mspconfig"]) assert msp is not None assert msp.backyard is not None # Parse Telemetry - if "telemetry" in data and data["telemetry"]: + if data.get("telemetry"): telem = Telemetry.load_xml(data["telemetry"]) assert telem is not None assert telem.backyard is not None diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 00b189f..d4c2459 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,8 +1,4 @@ -# pylint: skip-file -# type: ignore - -""" -Enhanced comprehensive tests for the OmniLogic protocol layer. +"""Enhanced comprehensive tests for the OmniLogic protocol layer. Focuses on: - OmniLogicMessage parsing and serialization (table-driven) @@ -12,31 +8,37 @@ - Connection lifecycle """ +from __future__ import annotations + import asyncio import struct import time import zlib +from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch from xml.etree import ElementTree as ET import pytest -from pytest_subtests import SubTests from pyomnilogic_local.api.exceptions import ( - OmniFragmentationException, - OmniMessageFormatException, - OmniTimeoutException, + OmniFragmentationError, + OmniMessageFormatError, + OmniTimeoutError, ) from pyomnilogic_local.api.protocol import OmniLogicMessage, OmniLogicProtocol from pyomnilogic_local.omnitypes import ClientType, MessageType +if TYPE_CHECKING: + from pytest_subtests import SubTests + + # ============================================================================ # OmniLogicMessage Tests # ============================================================================ def test_parse_basic_ack() -> None: - """Validate that we can parse a basic ACK packet""" + """Validate that we can parse a basic ACK packet.""" bytes_ack = b"\x99_\xd1l\x00\x00\x00\x00dv\x8f\xc11.20\x00\x00\x03\xea\x03\x00\x00\x00" message = OmniLogicMessage.from_bytes(bytes_ack) assert message.id == 2573193580 @@ -46,7 +48,7 @@ def test_parse_basic_ack() -> None: def test_create_basic_ack() -> None: - """Validate that we can create a valid basic ACK packet""" + """Validate that we can create a valid basic ACK packet.""" bytes_ack = b"\x99_\xd1l\x00\x00\x00\x00dv\x8f\xc11.20\x00\x00\x03\xea\x03\x00\x00\x00" message = OmniLogicMessage(2573193580, MessageType.ACK, payload=None, version="1.20") message.client_type = ClientType.OMNI @@ -72,7 +74,7 @@ def test_parse_leadmessage() -> None: def test_create_leadmessage() -> None: - """Validate that we can create a valid MSP LeadMessage""" + """Validate that we can create a valid MSP LeadMessage.""" bytes_leadmessage = ( b'\x00\x00\x90v\x00\x00\x00\x00dv\x92\xc11.20\x00\x00\x07\xce\x03\x00\x01\x00' b'LeadMessage' @@ -96,15 +98,14 @@ def test_create_leadmessage() -> None: def test_message_from_bytes_errors(subtests: SubTests) -> None: """Test OmniLogicMessage.from_bytes with various error conditions using table-driven approach.""" test_cases = [ - # (data, expected_error, description) - (b"short", OmniMessageFormatException, "message too short"), - (b"\x00" * 10, OmniMessageFormatException, "header too short"), + # (data, expected_error, description) # noqa: ERA001 + (b"short", OmniMessageFormatError, "message too short"), + (b"\x00" * 10, OmniMessageFormatError, "header too short"), ] for data, expected_error, description in test_cases: - with subtests.test(msg=description): - with pytest.raises(expected_error): - OmniLogicMessage.from_bytes(data) + with subtests.test(msg=description), pytest.raises(expected_error): + OmniLogicMessage.from_bytes(data) def test_message_from_bytes_invalid_message_type() -> None: @@ -122,7 +123,7 @@ def test_message_from_bytes_invalid_message_type() -> None: 0, # reserved ) - with pytest.raises(OmniMessageFormatException, match="Unknown message type"): + with pytest.raises(OmniMessageFormatError, match="Unknown message type"): OmniLogicMessage.from_bytes(header + b"payload") @@ -141,7 +142,7 @@ def test_message_from_bytes_invalid_client_type() -> None: 0, # reserved ) - with pytest.raises(OmniMessageFormatException, match="Unknown client type"): + with pytest.raises(OmniMessageFormatError, match="Unknown client type"): OmniLogicMessage.from_bytes(header + b"payload") @@ -271,9 +272,11 @@ def test_datagram_received_unexpected_exception(caplog: pytest.LogCaptureFixture protocol = OmniLogicProtocol() # Patch OmniLogicMessage.from_bytes to raise an unexpected exception - with patch("pyomnilogic_local.api.protocol.OmniLogicMessage.from_bytes", side_effect=RuntimeError("Unexpected")): - with caplog.at_level("ERROR"): - protocol.datagram_received(b"data", ("127.0.0.1", 12345)) + with ( + patch("pyomnilogic_local.api.protocol.OmniLogicMessage.from_bytes", side_effect=RuntimeError("Unexpected")), + caplog.at_level("ERROR"), + ): + protocol.datagram_received(b"data", ("127.0.0.1", 12345)) assert any("Unexpected error processing datagram" in r.message for r in caplog.records) assert protocol.error_queue.qsize() == 1 @@ -438,15 +441,13 @@ async def test_ensure_sent_timeout_and_retry_logs(caplog: pytest.LogCaptureFixtu protocol = OmniLogicProtocol() protocol.transport = MagicMock() - async def always_timeout(*args: object, **kwargs: object) -> None: + async def always_timeout(*args: object, **kwargs: object) -> None: # noqa: ARG001 await asyncio.sleep(0) - raise TimeoutError() + raise TimeoutError message = OmniLogicMessage(123, MessageType.REQUEST_CONFIGURATION) - with patch.object(protocol, "_wait_for_ack", always_timeout): - with caplog.at_level("WARNING"): - with pytest.raises(OmniTimeoutException): - await protocol._ensure_sent(message, max_attempts=3) + with patch.object(protocol, "_wait_for_ack", always_timeout), caplog.at_level("WARNING"), pytest.raises(OmniTimeoutError): + await protocol._ensure_sent(message, max_attempts=3) assert any("attempt 1/3" in r.message for r in caplog.records) assert any("attempt 2/3" in r.message for r in caplog.records) @@ -614,9 +615,8 @@ async def test_receive_file_decompression_error() -> None: await protocol.data_queue.put(response_msg) - with patch.object(protocol, "_send_ack", new_callable=AsyncMock): - with pytest.raises(OmniMessageFormatException, match="Failed to decompress"): - await protocol._receive_file() + with patch.object(protocol, "_send_ack", new_callable=AsyncMock), pytest.raises(OmniMessageFormatError, match="Failed to decompress"): + await protocol._receive_file() # ============================================================================ @@ -704,9 +704,11 @@ async def test_receive_file_fragmented_invalid_leadmessage() -> None: leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload="invalid xml") await protocol.data_queue.put(leadmsg) - with patch.object(protocol, "_send_ack", new_callable=AsyncMock): - with pytest.raises(OmniFragmentationException, match="Failed to parse LeadMessage"): - await protocol._receive_file() + with ( + patch.object(protocol, "_send_ack", new_callable=AsyncMock), + pytest.raises(OmniFragmentationError, match="Failed to parse LeadMessage"), + ): + await protocol._receive_file() @pytest.mark.asyncio @@ -726,9 +728,11 @@ async def test_receive_file_fragmented_timeout_waiting() -> None: await protocol.data_queue.put(leadmsg) # Don't put any BlockMessages - will timeout - with patch.object(protocol, "_send_ack", new_callable=AsyncMock): - with pytest.raises(OmniFragmentationException, match="Timeout receiving fragment"): - await protocol._receive_file() + with ( + patch.object(protocol, "_send_ack", new_callable=AsyncMock), + pytest.raises(OmniFragmentationError, match="Timeout receiving fragment"), + ): + await protocol._receive_file() @pytest.mark.asyncio @@ -751,7 +755,7 @@ async def test_receive_file_fragmented_max_wait_time_exceeded() -> None: with patch.object(protocol, "_send_ack", new_callable=AsyncMock), patch("time.time") as mock_time: mock_time.side_effect = [0, 31] # Start at 0, then 31 seconds later (> 30s max) - with pytest.raises(OmniFragmentationException, match="Timeout waiting for fragments"): + with pytest.raises(OmniFragmentationError, match="Timeout waiting for fragments"): await protocol._receive_file() diff --git a/uv.lock b/uv.lock index 53916e9..85b432e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12, <4.0.0" +requires-python = ">=3.13, <4.0.0" [[package]] name = "annotated-types" @@ -11,33 +11,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "astroid" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/d1/6eee8726a863f28ff50d26c5eacb1a590f96ccbb273ce0a8c047ffb10f5a/astroid-4.0.1.tar.gz", hash = "sha256:0d778ec0def05b935e198412e62f9bcca8b3b5c39fdbe50b0ba074005e477aab", size = 405414, upload-time = "2025-10-11T15:15:42.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/f4/034361a9cbd9284ef40c8ad107955ede4efae29cbc17a059f63f6569c06a/astroid-4.0.1-py3-none-any.whl", hash = "sha256:37ab2f107d14dc173412327febf6c78d39590fdafcb44868f03b6c03452e3db0", size = 276268, upload-time = "2025-10-11T15:15:40.585Z" }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - -[[package]] -name = "cfgv" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, -] - [[package]] name = "click" version = "8.3.0" @@ -59,245 +32,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "coverage" -version = "7.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/db/86f6906a7c7edc1a52b2c6682d6dd9be775d73c0dfe2b84f8923dfea5784/coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1", size = 216098, upload-time = "2025-10-15T15:13:02.916Z" }, - { url = "https://files.pythonhosted.org/packages/21/54/e7b26157048c7ba555596aad8569ff903d6cd67867d41b75287323678ede/coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007", size = 216331, upload-time = "2025-10-15T15:13:04.403Z" }, - { url = "https://files.pythonhosted.org/packages/b9/19/1ce6bf444f858b83a733171306134a0544eaddf1ca8851ede6540a55b2ad/coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46", size = 247825, upload-time = "2025-10-15T15:13:05.92Z" }, - { url = "https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893", size = 250573, upload-time = "2025-10-15T15:13:07.471Z" }, - { url = "https://files.pythonhosted.org/packages/58/8d/b0ff3641a320abb047258d36ed1c21d16be33beed4152628331a1baf3365/coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115", size = 251706, upload-time = "2025-10-15T15:13:09.4Z" }, - { url = "https://files.pythonhosted.org/packages/59/c8/5a586fe8c7b0458053d9c687f5cff515a74b66c85931f7fe17a1c958b4ac/coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415", size = 248221, upload-time = "2025-10-15T15:13:10.964Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ff/3a25e3132804ba44cfa9a778cdf2b73dbbe63ef4b0945e39602fc896ba52/coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186", size = 249624, upload-time = "2025-10-15T15:13:12.5Z" }, - { url = "https://files.pythonhosted.org/packages/c5/12/ff10c8ce3895e1b17a73485ea79ebc1896a9e466a9d0f4aef63e0d17b718/coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d", size = 247744, upload-time = "2025-10-15T15:13:14.554Z" }, - { url = "https://files.pythonhosted.org/packages/16/02/d500b91f5471b2975947e0629b8980e5e90786fe316b6d7299852c1d793d/coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d", size = 247325, upload-time = "2025-10-15T15:13:16.438Z" }, - { url = "https://files.pythonhosted.org/packages/77/11/dee0284fbbd9cd64cfce806b827452c6df3f100d9e66188e82dfe771d4af/coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2", size = 249180, upload-time = "2025-10-15T15:13:17.959Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/cdf1def928f0a150a057cab03286774e73e29c2395f0d30ce3d9e9f8e697/coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5", size = 218479, upload-time = "2025-10-15T15:13:19.608Z" }, - { url = "https://files.pythonhosted.org/packages/ff/55/e5884d55e031da9c15b94b90a23beccc9d6beee65e9835cd6da0a79e4f3a/coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0", size = 219290, upload-time = "2025-10-15T15:13:21.593Z" }, - { url = "https://files.pythonhosted.org/packages/23/a8/faa930cfc71c1d16bc78f9a19bb73700464f9c331d9e547bfbc1dbd3a108/coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad", size = 217924, upload-time = "2025-10-15T15:13:23.39Z" }, - { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" }, - { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" }, - { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" }, - { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" }, - { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" }, - { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" }, - { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" }, - { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" }, - { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" }, - { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" }, - { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" }, - { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" }, - { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" }, - { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" }, - { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" }, - { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" }, - { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" }, - { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, - { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, - { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, - { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, - { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, - { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, - { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, - { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, - { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, - { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, - { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, - { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, - { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, - { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, - { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, - { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, -] - -[[package]] -name = "dill" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, -] - -[[package]] -name = "distlib" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, -] - -[[package]] -name = "filelock" -version = "3.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, -] - -[[package]] -name = "identify" -version = "2.6.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "isort" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, -] - -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, -] - -[[package]] -name = "mypy" -version = "1.18.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, - { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, - { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, - { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, - { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, - { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, - { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, - { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, - { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, - { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, - { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, - { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pre-commit" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, -] - [[package]] name = "pydantic" version = "2.12.3" @@ -322,20 +56,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, - { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, - { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, - { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, - { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, - { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, - { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, - { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, - { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, - { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, - { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, @@ -374,99 +94,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, - { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, - { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pylint" -version = "4.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "astroid" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "dill" }, - { name = "isort" }, - { name = "mccabe" }, - { name = "platformdirs" }, - { name = "tomlkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/f8/2feda2bc72654811f2596e856c33c2a98225fba717df2b55c8d6a1f5cdad/pylint-4.0.2.tar.gz", hash = "sha256:9c22dfa52781d3b79ce86ab2463940f874921a3e5707bcfc98dd0c019945014e", size = 1572401, upload-time = "2025-10-20T13:02:34.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/8b/2e814a255436fc6d604a60f1e8b8a186e05082aa3c0cabfd9330192496a2/pylint-4.0.2-py3-none-any.whl", hash = "sha256:9627ccd129893fb8ee8e8010261cb13485daca83e61a6f854a85528ee579502d", size = 536019, upload-time = "2025-10-20T13:02:32.778Z" }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, -] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage" }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, -] - -[[package]] -name = "pytest-subtests" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" }, ] [[package]] name = "python-omnilogic-local" version = "0.19.0" -source = { editable = "." } +source = { virtual = "." } dependencies = [ { name = "click" }, { name = "pydantic" }, @@ -478,17 +111,6 @@ cli = [ { name = "scapy" }, ] -[package.dev-dependencies] -dev = [ - { name = "mypy" }, - { name = "pre-commit" }, - { name = "pylint" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "pytest-subtests" }, -] - [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.0.0,<8.4.0" }, @@ -498,63 +120,6 @@ requires-dist = [ ] provides-extras = ["cli"] -[package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=1.18.2,<2.0.0" }, - { name = "pre-commit", specifier = ">=4.0.0,<5.0.0" }, - { name = "pylint", specifier = ">=4.0.0,<5.0.0" }, - { name = "pytest", specifier = ">=8.0.0,<9.0.0" }, - { name = "pytest-asyncio", specifier = ">=1.2.0,<2.0.0" }, - { name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" }, - { name = "pytest-subtests", specifier = ">=0.15.0,<1.0.0" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - [[package]] name = "scapy" version = "2.6.1" @@ -564,15 +129,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/34/8695b43af99d0c796e4b7933a0d7df8925f43a8abdd0ff0f6297beb4de3a/scapy-2.6.1-py3-none-any.whl", hash = "sha256:88a998572049b511a1f3e44f4aa7c62dd39c6ea2aa1bb58434f503956641789d", size = 2420670, upload-time = "2024-11-05T08:43:21.285Z" }, ] -[[package]] -name = "tomlkit" -version = "0.13.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -594,20 +150,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "virtualenv" -version = "20.35.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a4/d5/b0ccd381d55c8f45d46f77df6ae59fbc23d19e901e2d523395598e5f4c93/virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44", size = 6002907, upload-time = "2025-10-10T21:23:33.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a", size = 5981061, upload-time = "2025-10-10T21:23:30.433Z" }, -] - [[package]] name = "xmltodict" version = "1.0.2" From b4f247d6526ea33803c0709b59d4adcf51d2937a Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:06:07 -0600 Subject: [PATCH 58/61] chore: fix typing in tests --- .pre-commit-config.yaml | 4 +- .vscode/settings.json | 2 +- pyproject.toml | 20 ++- tests/test_api.py | 22 ++- tests/test_decorators.py | 24 +-- tests/test_filter_pump.py | 95 +++++----- uv.lock | 358 +++++++++++++++++++++++++++++++++++++- 7 files changed, 446 insertions(+), 79 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0fcbc9c..c3642cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,5 +57,5 @@ repos: hooks: - id: mypy exclude: cli.py - additional_dependencies: [ "pydantic>=2.0.0", "pydantic-xml>=2.18.0", "pytest>=8.0.0" ] - args: [ "--config-file=./pyproject.toml", "--follow-imports=silent", "--strict", "--ignore-missing-imports", "--disallow-subclassing-any", "--no-warn-return-any" ] + additional_dependencies: [ "pydantic>=2.0.0", "pytest>=8.0.0" ] + args: [ "--config-file=./pyproject.toml"] diff --git a/.vscode/settings.json b/.vscode/settings.json index fd02a96..15de615 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,7 @@ "--cov-report=xml:coverage.xml", "tests" ], - "mypy-type-checker.importStrategy": "fromEnvironment", + // "mypy-type-checker.importStrategy": "fromEnvironment", "python.analysis.typeCheckingMode": "basic", "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", diff --git a/pyproject.toml b/pyproject.toml index 42b8cb3..11e9d57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ license = {text = "Apache-2.0"} dependencies = [ "pydantic >=2.0.0,<3.0.0", "click >=8.0.0,<8.4.0", - "xmltodict >=1.0.2,<2.0.0", + "xmltodict >=1.0.1,<2.0.0", ] [project.scripts] @@ -24,12 +24,22 @@ cli = [ "scapy>=2.6.1,<3.0.0", ] +[dependency-groups] +dev = [ + "pre-commit>=4.0.0,<5.0.0", + "mypy>=1.18.2,<2.0.0", + "types-xmltodict >=1.0.1,<2.0.0", + "pytest>=8.0.0,<9.0.0", + "pytest-cov>=7.0.0,<8.0.0", + "pytest-asyncio>=1.2.0,<2.0.0", + "pytest-subtests>=0.15.0,<1.0.0", +] + [tool.mypy] python_version = "3.13" -plugins = [ - "pydantic.mypy", - "pydantic_xml.mypy", -] +# plugins = [ +# "pydantic.mypy" +# ] follow_imports = "silent" strict = true ignore_missing_imports = true diff --git a/tests/test_api.py b/tests/test_api.py index d1b99a6..2700a4a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,3 @@ -# type: ignore - """Comprehensive tests for the OmniLogic API layer. Focuses on: @@ -11,7 +9,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch from xml.etree import ElementTree as ET @@ -20,7 +18,7 @@ from pyomnilogic_local.api.api import OmniLogicAPI, _validate_id, _validate_speed, _validate_temperature from pyomnilogic_local.api.constants import MAX_SPEED_PERCENT, MAX_TEMPERATURE_F, MIN_SPEED_PERCENT, MIN_TEMPERATURE_F, XML_NAMESPACE from pyomnilogic_local.api.exceptions import OmniValidationError -from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicShow40, ColorLogicSpeed, HeaterMode +from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicShow40, ColorLogicSpeed, HeaterMode, MessageType if TYPE_CHECKING: from pytest_subtests import SubTests @@ -60,7 +58,7 @@ def _find_param(root: ET.Element, name: str) -> ET.Element: def test_validate_temperature(subtests: SubTests) -> None: """Test temperature validation with various inputs using table-driven approach.""" - test_cases = [ + test_cases: list[tuple[Any, str, bool, str]] = [ # (temperature, param_name, should_pass, description) # noqa: ERA001 (MIN_TEMPERATURE_F, "temp", True, "minimum valid temperature"), (MAX_TEMPERATURE_F, "temp", True, "maximum valid temperature"), @@ -83,7 +81,7 @@ def test_validate_temperature(subtests: SubTests) -> None: def test_validate_speed(subtests: SubTests) -> None: """Test speed validation with various inputs using table-driven approach.""" - test_cases = [ + test_cases: list[tuple[Any, str, bool, str]] = [ # (speed, param_name, should_pass, description) # noqa: ERA001 (MIN_SPEED_PERCENT, "speed", True, "minimum valid speed (0)"), (MAX_SPEED_PERCENT, "speed", True, "maximum valid speed (100)"), @@ -106,7 +104,7 @@ def test_validate_speed(subtests: SubTests) -> None: def test_validate_id(subtests: SubTests) -> None: """Test ID validation with various inputs using table-driven approach.""" - test_cases = [ + test_cases: list[tuple[Any, str, bool, str]] = [ # (id_value, param_name, should_pass, description) # noqa: ERA001 (0, "pool_id", True, "zero ID"), (1, "pool_id", True, "positive ID"), @@ -149,7 +147,7 @@ def test_api_init_custom_params() -> None: def test_api_init_validation(subtests: SubTests) -> None: """Test OmniLogicAPI initialization validation using table-driven approach.""" - test_cases = [ + test_cases: list[tuple[Any, Any, Any, bool, str]] = [ # (ip, port, timeout, should_pass, description) # noqa: ERA001 ("", 10444, 5.0, False, "empty IP address"), ("192.168.1.100", 0, 5.0, False, "zero port"), @@ -489,7 +487,7 @@ async def test_async_send_message_creates_transport() -> None: with patch("asyncio.get_running_loop") as mock_loop: mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) - await api.async_send_message(0x01, "test", need_response=False) + await api.async_send_message(MessageType.REQUEST_CONFIGURATION, "test", need_response=False) # Verify endpoint was created with correct parameters mock_loop.return_value.create_datagram_endpoint.assert_called_once() @@ -512,7 +510,7 @@ async def test_async_send_message_with_response() -> None: with patch("asyncio.get_running_loop") as mock_loop: mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) - result = await api.async_send_message(0x01, "test", need_response=True) + result = await api.async_send_message(MessageType.REQUEST_CONFIGURATION, "test", need_response=True) assert result == "test response" mock_protocol.send_and_receive.assert_called_once() @@ -531,7 +529,7 @@ async def test_async_send_message_without_response() -> None: with patch("asyncio.get_running_loop") as mock_loop: mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) - result = await api.async_send_message(0x01, "test", need_response=False) + result = await api.async_send_message(MessageType.REQUEST_CONFIGURATION, "test", need_response=False) # type: ignore[func-returns-value] assert result is None mock_protocol.send_message.assert_called_once() @@ -551,7 +549,7 @@ async def test_async_send_message_closes_transport_on_error() -> None: mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) with pytest.raises(Exception, match="Test error"): - await api.async_send_message(0x01, "test", need_response=False) + await api.async_send_message(MessageType.REQUEST_CONFIGURATION, "test", need_response=False) # Verify transport was still closed despite the error mock_transport.close.assert_called_once() diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 3fdf4f3..9b52874 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,4 +1,3 @@ -# type: ignore """Tests for equipment control method decorators. Focuses on: @@ -10,6 +9,7 @@ from __future__ import annotations +from typing import Any from unittest.mock import MagicMock import pytest @@ -35,8 +35,8 @@ def __init__(self, is_ready: bool = True): self._omni = MagicMock() self._omni._telemetry_dirty = False self.method_called = False - self.method_args = None - self.method_kwargs = None + self.method_args: tuple[Any, ...] | None = None + self.method_kwargs: dict[str, Any] | None = None @control_method async def turn_on(self) -> None: @@ -69,7 +69,7 @@ async def set_complex_operation(self, param1: int, param2: str, flag: bool = Fal @pytest.mark.asyncio -async def test_control_method_when_ready_executes_function(): +async def test_control_method_when_ready_executes_function() -> None: """Test that control_method executes the wrapped function when equipment is ready.""" equipment = MockEquipment(is_ready=True) @@ -79,7 +79,7 @@ async def test_control_method_when_ready_executes_function(): @pytest.mark.asyncio -async def test_control_method_when_not_ready_raises_error(): +async def test_control_method_when_not_ready_raises_error() -> None: """Test that control_method raises OmniEquipmentNotReadyError when equipment is not ready.""" equipment = MockEquipment(is_ready=False) @@ -91,7 +91,7 @@ async def test_control_method_when_not_ready_raises_error(): @pytest.mark.asyncio -async def test_control_method_marks_telemetry_dirty(): +async def test_control_method_marks_telemetry_dirty() -> None: """Test that control_method marks telemetry as dirty after successful execution.""" equipment = MockEquipment(is_ready=True) @@ -103,7 +103,7 @@ async def test_control_method_marks_telemetry_dirty(): @pytest.mark.asyncio -async def test_control_method_does_not_mark_dirty_if_not_ready(): +async def test_control_method_does_not_mark_dirty_if_not_ready() -> None: """Test that control_method does not mark state dirty if readiness check fails.""" equipment = MockEquipment(is_ready=False) @@ -114,7 +114,7 @@ async def test_control_method_does_not_mark_dirty_if_not_ready(): @pytest.mark.asyncio -async def test_control_method_passes_arguments(): +async def test_control_method_passes_arguments() -> None: """Test that control_method properly passes arguments to wrapped function.""" equipment = MockEquipment(is_ready=True) @@ -125,7 +125,7 @@ async def test_control_method_passes_arguments(): @pytest.mark.asyncio -async def test_control_method_passes_kwargs(): +async def test_control_method_passes_kwargs() -> None: """Test that control_method properly passes keyword arguments to wrapped function.""" equipment = MockEquipment(is_ready=True) @@ -138,7 +138,7 @@ async def test_control_method_passes_kwargs(): @pytest.mark.asyncio -async def test_control_method_error_message_for_different_methods(): +async def test_control_method_error_message_for_different_methods() -> None: """Test that control_method generates appropriate error messages for different method names.""" equipment = MockEquipment(is_ready=False) @@ -164,7 +164,7 @@ async def test_control_method_error_message_for_different_methods(): @pytest.mark.asyncio -async def test_control_method_preserves_function_metadata(): +async def test_control_method_preserves_function_metadata() -> None: """Test that control_method preserves the wrapped function's metadata.""" equipment = MockEquipment(is_ready=True) @@ -177,7 +177,7 @@ async def test_control_method_preserves_function_metadata(): @pytest.mark.asyncio -async def test_control_method_without_omni_reference(): +async def test_control_method_without_omni_reference() -> None: """Test that control_method logs warning when equipment lacks _omni reference.""" equipment = MockEquipment(is_ready=True) del equipment._omni diff --git a/tests/test_filter_pump.py b/tests/test_filter_pump.py index fb6d608..b048b6b 100644 --- a/tests/test_filter_pump.py +++ b/tests/test_filter_pump.py @@ -1,4 +1,3 @@ -# type: ignore """Tests for Filter and Pump equipment classes.""" from __future__ import annotations @@ -13,14 +12,16 @@ from pyomnilogic_local.omnitypes import ( FilterSpeedPresets, FilterState, - OmniType, + FilterType, + PumpFunction, PumpState, + PumpType, ) from pyomnilogic_local.pump import Pump @pytest.fixture -def mock_omni(): +def mock_omni() -> Mock: """Create a mock OmniLogic instance.""" omni = Mock() omni._api = Mock() @@ -28,30 +29,31 @@ def mock_omni(): @pytest.fixture -def sample_filter_config(): +def sample_filter_config() -> MSPFilter: """Create a sample filter configuration.""" - return MSPFilter( + return MSPFilter.model_construct( + None, **{ "System-Id": 8, "Name": "Test Filter", - "Filter-Type": "FMT_VARIABLE_SPEED_PUMP", + "Filter-Type": FilterType.VARIABLE_SPEED, "Max-Pump-Speed": 100, "Min-Pump-Speed": 30, "Max-Pump-RPM": 3450, "Min-Pump-RPM": 1000, - "Priming-Enabled": "yes", + "Priming-Enabled": True, "Vsp-Low-Pump-Speed": 40, "Vsp-Medium-Pump-Speed": 60, "Vsp-High-Pump-Speed": 80, - } + }, ) @pytest.fixture -def sample_filter_telemetry(): +def sample_filter_telemetry() -> TelemetryFilter: """Create sample filter telemetry.""" - return TelemetryFilter( - omni_type=OmniType.FILTER, + return TelemetryFilter.model_construct( + None, **{ "@systemId": 8, "@filterState": FilterState.ON, @@ -66,31 +68,32 @@ def sample_filter_telemetry(): @pytest.fixture -def sample_pump_config(): +def sample_pump_config() -> MSPPump: """Create a sample pump configuration.""" - return MSPPump( + return MSPPump.model_construct( + None, **{ "System-Id": 15, "Name": "Test Pump", - "Type": "PMP_VARIABLE_SPEED_PUMP", - "Function": "PMP_PUMP", + "Type": PumpType.VARIABLE_SPEED, + "Function": PumpFunction.PUMP, "Max-Pump-Speed": 100, "Min-Pump-Speed": 30, "Max-Pump-RPM": 3450, "Min-Pump-RPM": 1000, - "Priming-Enabled": "yes", + "Priming-Enabled": True, "Vsp-Low-Pump-Speed": 40, "Vsp-Medium-Pump-Speed": 60, "Vsp-High-Pump-Speed": 80, - } + }, ) @pytest.fixture -def sample_pump_telemetry(): +def sample_pump_telemetry() -> TelemetryPump: """Create sample pump telemetry.""" - return TelemetryPump( - omni_type=OmniType.PUMP, + return TelemetryPump.model_construct( + None, **{ "@systemId": 15, "@pumpState": PumpState.ON, @@ -102,7 +105,7 @@ def sample_pump_telemetry(): @pytest.fixture -def mock_telemetry(sample_filter_telemetry, sample_pump_telemetry): +def mock_telemetry(sample_filter_telemetry: TelemetryFilter, sample_pump_telemetry: TelemetryPump) -> Mock: """Create a mock Telemetry object.""" telemetry = Mock(spec=Telemetry) telemetry.get_telem_by_systemid = Mock( @@ -114,7 +117,7 @@ def mock_telemetry(sample_filter_telemetry, sample_pump_telemetry): class TestFilter: """Tests for Filter class.""" - def test_filter_properties_config(self, mock_omni, sample_filter_config, mock_telemetry): + def test_filter_properties_config(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test that filter config properties are correctly exposed.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) @@ -129,7 +132,7 @@ def test_filter_properties_config(self, mock_omni, sample_filter_config, mock_te assert filter_obj.medium_speed == 60 assert filter_obj.high_speed == 80 - def test_filter_properties_telemetry(self, mock_omni, sample_filter_config, mock_telemetry): + def test_filter_properties_telemetry(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test that filter telemetry properties are correctly exposed.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) @@ -142,14 +145,14 @@ def test_filter_properties_telemetry(self, mock_omni, sample_filter_config, mock assert filter_obj.power == 500 assert filter_obj.last_speed == 50 - def test_filter_is_on_true(self, mock_omni, sample_filter_config, mock_telemetry): + def test_filter_is_on_true(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test is_on returns True when filter is on.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) assert filter_obj.is_on is True - def test_filter_is_on_false(self, mock_omni, sample_filter_config, mock_telemetry): + def test_filter_is_on_false(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test is_on returns False when filter is off.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) @@ -157,7 +160,7 @@ def test_filter_is_on_false(self, mock_omni, sample_filter_config, mock_telemetr assert filter_obj.is_on is False - def test_filter_is_ready_true(self, mock_omni, sample_filter_config, mock_telemetry): + def test_filter_is_ready_true(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test is_ready returns True for stable states.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) @@ -170,7 +173,7 @@ def test_filter_is_ready_true(self, mock_omni, sample_filter_config, mock_teleme filter_obj.telemetry.state = FilterState.OFF assert filter_obj.is_ready is True - def test_filter_is_ready_false(self, mock_omni, sample_filter_config, mock_telemetry): + def test_filter_is_ready_false(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test is_ready returns False for transitional states.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) @@ -184,11 +187,11 @@ def test_filter_is_ready_false(self, mock_omni, sample_filter_config, mock_telem assert filter_obj.is_ready is False @pytest.mark.asyncio - async def test_filter_turn_on(self, mock_omni, sample_filter_config, mock_telemetry): + async def test_filter_turn_on(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test turn_on method calls API correctly.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) - filter_obj._api.async_set_equipment = AsyncMock() + filter_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] await filter_obj.turn_on() @@ -199,11 +202,11 @@ async def test_filter_turn_on(self, mock_omni, sample_filter_config, mock_teleme ) @pytest.mark.asyncio - async def test_filter_turn_off(self, mock_omni, sample_filter_config, mock_telemetry): + async def test_filter_turn_off(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test turn_off method calls API correctly.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) - filter_obj._api.async_set_equipment = AsyncMock() + filter_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] await filter_obj.turn_off() @@ -214,11 +217,11 @@ async def test_filter_turn_off(self, mock_omni, sample_filter_config, mock_telem ) @pytest.mark.asyncio - async def test_filter_run_preset_speed_low(self, mock_omni, sample_filter_config, mock_telemetry): + async def test_filter_run_preset_speed_low(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test run_preset_speed with LOW preset.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) - filter_obj._api.async_set_equipment = AsyncMock() + filter_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] await filter_obj.run_preset_speed(FilterSpeedPresets.LOW) @@ -229,11 +232,11 @@ async def test_filter_run_preset_speed_low(self, mock_omni, sample_filter_config ) @pytest.mark.asyncio - async def test_filter_run_preset_speed_medium(self, mock_omni, sample_filter_config, mock_telemetry): + async def test_filter_run_preset_speed_medium(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test run_preset_speed with MEDIUM preset.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) - filter_obj._api.async_set_equipment = AsyncMock() + filter_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] await filter_obj.run_preset_speed(FilterSpeedPresets.MEDIUM) @@ -244,11 +247,11 @@ async def test_filter_run_preset_speed_medium(self, mock_omni, sample_filter_con ) @pytest.mark.asyncio - async def test_filter_run_preset_speed_high(self, mock_omni, sample_filter_config, mock_telemetry): + async def test_filter_run_preset_speed_high(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: """Test run_preset_speed with HIGH preset.""" sample_filter_config.bow_id = 7 filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) - filter_obj._api.async_set_equipment = AsyncMock() + filter_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] await filter_obj.run_preset_speed(FilterSpeedPresets.HIGH) @@ -262,7 +265,7 @@ async def test_filter_run_preset_speed_high(self, mock_omni, sample_filter_confi class TestPump: """Tests for Pump class.""" - def test_pump_properties_config(self, mock_omni, sample_pump_config, mock_telemetry): + def test_pump_properties_config(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: """Test that pump config properties are correctly exposed.""" sample_pump_config.bow_id = 7 pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) @@ -278,7 +281,7 @@ def test_pump_properties_config(self, mock_omni, sample_pump_config, mock_teleme assert pump_obj.medium_speed == 60 assert pump_obj.high_speed == 80 - def test_pump_properties_telemetry(self, mock_omni, sample_pump_config, mock_telemetry): + def test_pump_properties_telemetry(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: """Test that pump telemetry properties are correctly exposed.""" sample_pump_config.bow_id = 7 pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) @@ -288,14 +291,14 @@ def test_pump_properties_telemetry(self, mock_omni, sample_pump_config, mock_tel assert pump_obj.last_speed == 50 assert pump_obj.why_on == 11 - def test_pump_is_on_true(self, mock_omni, sample_pump_config, mock_telemetry): + def test_pump_is_on_true(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: """Test is_on returns True when pump is on.""" sample_pump_config.bow_id = 7 pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) assert pump_obj.is_on is True - def test_pump_is_on_false(self, mock_omni, sample_pump_config, mock_telemetry): + def test_pump_is_on_false(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: """Test is_on returns False when pump is off.""" sample_pump_config.bow_id = 7 pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) @@ -303,7 +306,7 @@ def test_pump_is_on_false(self, mock_omni, sample_pump_config, mock_telemetry): assert pump_obj.is_on is False - def test_pump_is_ready(self, mock_omni, sample_pump_config, mock_telemetry): + def test_pump_is_ready(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: """Test is_ready returns True for stable states.""" sample_pump_config.bow_id = 7 pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) @@ -317,11 +320,11 @@ def test_pump_is_ready(self, mock_omni, sample_pump_config, mock_telemetry): assert pump_obj.is_ready is True @pytest.mark.asyncio - async def test_pump_turn_on(self, mock_omni, sample_pump_config, mock_telemetry): + async def test_pump_turn_on(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: """Test turn_on method calls API correctly.""" sample_pump_config.bow_id = 7 pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) - pump_obj._api.async_set_equipment = AsyncMock() + pump_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] await pump_obj.turn_on() @@ -332,11 +335,11 @@ async def test_pump_turn_on(self, mock_omni, sample_pump_config, mock_telemetry) ) @pytest.mark.asyncio - async def test_pump_turn_off(self, mock_omni, sample_pump_config, mock_telemetry): + async def test_pump_turn_off(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: """Test turn_off method calls API correctly.""" sample_pump_config.bow_id = 7 pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) - pump_obj._api.async_set_equipment = AsyncMock() + pump_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] await pump_obj.turn_off() diff --git a/uv.lock b/uv.lock index 85b432e..161804d 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + [[package]] name = "click" version = "8.3.0" @@ -32,6 +50,199 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/e6/7c4006cf689ed7a4aa75dcf1f14acbc04e585714c220b5cc6d231096685a/coverage-7.11.2.tar.gz", hash = "sha256:ae43149b7732df15c3ca9879b310c48b71d08cd8a7ba77fda7f9108f78499e93", size = 814849, upload-time = "2025-11-08T20:26:33.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/00/57f3f8adaced9e4c74f482932e093176df7e400b4bb95dc1f3cd499511b5/coverage-7.11.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:38a5509fe7fabb6fb3161059b947641753b6529150ef483fc01c4516a546f2ad", size = 217125, upload-time = "2025-11-08T20:24:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2a/ff1a55673161608c895080950cdfbb6485c95e6fa57a92d2cd1e463717b3/coverage-7.11.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7e01ab8d69b6cffa2463e78a4d760a6b69dfebe5bf21837eabcc273655c7e7b3", size = 217499, upload-time = "2025-11-08T20:24:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/eaac01709ffbef291a12ca2526b6247f55ab17724e2297cc70921cd9a81f/coverage-7.11.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4776c6555a9f378f37fa06408f2e1cc1d06e4c4e06adb3d157a4926b549efbe", size = 248479, upload-time = "2025-11-08T20:24:54.825Z" }, + { url = "https://files.pythonhosted.org/packages/75/25/d846d2d08d182eeb30d1eba839fabdd9a3e6c710a1f187657b9c697bab23/coverage-7.11.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f70fa1ef17cba5dada94e144ea1b6e117d4f174666842d1da3aaf765d6eb477", size = 251074, upload-time = "2025-11-08T20:24:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7a/34c9402ad12bce609be4be1146a7d22a7fae8e9d752684b6315cce552a65/coverage-7.11.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:811bff1f93566a8556a9aeb078bd82573e37f4d802a185fba4cbe75468615050", size = 252318, upload-time = "2025-11-08T20:24:57.987Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/292fe3cea4cc1c4b8fb060fa60e565ab1b3bfc67bda74bedefb24b4a2407/coverage-7.11.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d0e80c9946da61cc0bf55dfd90d65707acc1aa5bdcb551d4285ea8906255bb33", size = 248641, upload-time = "2025-11-08T20:24:59.642Z" }, + { url = "https://files.pythonhosted.org/packages/c5/af/33ccb2aa2f43bbc330a1fccf84a396b90f2e61c00dccb7b72b2993a3c795/coverage-7.11.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:10f10c9acf584ef82bfaaa7296163bd11c7487237f1670e81fc2fa7e972be67b", size = 250457, upload-time = "2025-11-08T20:25:01.358Z" }, + { url = "https://files.pythonhosted.org/packages/bd/91/4b5b58f34e0587fbc5c1a28d644d9c20c13349c1072aea507b6e372c8f20/coverage-7.11.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fd3f7cc6cb999e3eff91a2998a70c662b0fcd3c123d875766147c530ca0d3248", size = 248421, upload-time = "2025-11-08T20:25:02.895Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d5/5c5ed220b15f490717522d241629c522fa22275549a6ccfbc96a3654b009/coverage-7.11.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e52a028a56889d3ad036c0420e866e4a69417d3203e2fc5f03dcb8841274b64c", size = 248244, upload-time = "2025-11-08T20:25:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/1e/27/504088aba40735132db838711d966e1314931ff9bddcd0e2ea6bc7e345a7/coverage-7.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f6f985e175dfa1fb8c0a01f47186720ae25d5e20c181cc5f3b9eba95589b8148", size = 250004, upload-time = "2025-11-08T20:25:06.633Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/4d61c0ad0d39656bd5e73fe41a93a34b063c90333258e6307aadcfcdbb97/coverage-7.11.2-cp313-cp313-win32.whl", hash = "sha256:e48b95abe2983be98cdf52900e07127eb7fe7067c87a700851f4f1f53d2b00e6", size = 219639, upload-time = "2025-11-08T20:25:08.27Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/a298afa025ebe7a2afd6657871a1ac2d9c49666ce00f9a35ee9df61a3bd8/coverage-7.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:ea910cc737ee8553c81ad5c104bc5b135106ebb36f88be506c3493e001b4c733", size = 220445, upload-time = "2025-11-08T20:25:09.906Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a1/1825f5eadc0a0a6ea1c6e678827e1ec8c0494dbd23270016fccfc3358fbf/coverage-7.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:ef2d3081562cd83f97984a96e02e7a294efa28f58d5e7f4e28920f59fd752b41", size = 219077, upload-time = "2025-11-08T20:25:11.777Z" }, + { url = "https://files.pythonhosted.org/packages/c0/61/98336c6f4545690b482e805c3a1a83fb2db4c19076307b187db3d421b5b3/coverage-7.11.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:87d7c7b0b2279e174f36d276e2afb7bf16c9ea04e824d4fa277eea1854f4cfd4", size = 217818, upload-time = "2025-11-08T20:25:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/ee/6dca6e5f1a4affba8d3224996d0e9145e6d67817da753cc436e48bb8d0e6/coverage-7.11.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:940d195f4c8ba3ec6e7c302c9f546cdbe63e57289ed535452bc52089b1634f1c", size = 218170, upload-time = "2025-11-08T20:25:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/ec/17/9c9ca3ef09d3576027e77cf580eb599d8d655f9ca2456a26ca50c53e07e3/coverage-7.11.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3b92e10ca996b5421232dd6629b9933f97eb57ce374bca800ab56681fbeda2b", size = 259466, upload-time = "2025-11-08T20:25:17.373Z" }, + { url = "https://files.pythonhosted.org/packages/53/96/2001a596827a0b91ba5f627f21b5ce998fa1f27d861a8f6d909f5ea663ff/coverage-7.11.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61d6a7cc1e7a7a761ac59dcc88cee54219fd4231face52bd1257cfd3df29ae9f", size = 261530, upload-time = "2025-11-08T20:25:19.085Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bb/fea7007035fdc3c40fcca0ab740da549ff9d38fa50b0d37cd808fbbf9683/coverage-7.11.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bee1911c44c52cad6b51d436aa8c6ff5ca5d414fa089c7444592df9e7b890be9", size = 263963, upload-time = "2025-11-08T20:25:21.168Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b3/7452071353441b632ebea42f6ad328a7ab592e4bc50a31f9921b41667017/coverage-7.11.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4c4423ea9c28749080b41e18ec74d658e6c9f148a6b47e719f3d7f56197f8227", size = 258644, upload-time = "2025-11-08T20:25:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/6e56b1c2b3308f587508ad4b0a4cb76c8d6179fea2df148e071979b3eb77/coverage-7.11.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:689d3b4dd0d4c912ed8bfd7a1b5ff2c5ecb1fa16571840573174704ff5437862", size = 261539, upload-time = "2025-11-08T20:25:25.277Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/7afeeac2a49f651318e4a83f1d5f4d3d4f4092f1d451ac4aec8069cddbdb/coverage-7.11.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:75ef769be19d69ea71b0417d7fbf090032c444792579cdf9b166346a340987d5", size = 259153, upload-time = "2025-11-08T20:25:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/08f3b5c7500b2031cee74e8a01f9a5bc407f781ff6a826707563bb9dd5b7/coverage-7.11.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6681164bc697b93676945c8c814b76ac72204c395e11b71ba796a93b33331c24", size = 258043, upload-time = "2025-11-08T20:25:30.087Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/8e080e7622bd7c82df0f8324bbe0461ed1032a638b80046f1a53a88ea3a8/coverage-7.11.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4aa799c61869318d2b86c0d3c413d6805546aec42069f009cbb27df2eefb2790", size = 260243, upload-time = "2025-11-08T20:25:31.722Z" }, + { url = "https://files.pythonhosted.org/packages/dc/75/da033d8589661527b4a6d30c414005467e48fbccc0f3c10898af183e14e1/coverage-7.11.2-cp313-cp313t-win32.whl", hash = "sha256:9a6468e1a3a40d3d1f9120a9ff221d3eacef4540a6f819fff58868fe0bd44fa9", size = 220309, upload-time = "2025-11-08T20:25:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/29/ef/8a477d41dbcde1f1179c13c43c9f77ee926b793fe3e5f1cf5d868a494679/coverage-7.11.2-cp313-cp313t-win_amd64.whl", hash = "sha256:30c437e8b51ce081fe3903c9e368e85c9a803b093fd062c49215f3bf4fd1df37", size = 221374, upload-time = "2025-11-08T20:25:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/4c3cdd737ed1f630b821430004c2d5f1088b9bc0a7115aa5ad7c40d7d5cb/coverage-7.11.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a35701fe0b5ee9d4b67d31aa76555237af32a36b0cf8dd33f8a74470cf7cd2f5", size = 219648, upload-time = "2025-11-08T20:25:37.572Z" }, + { url = "https://files.pythonhosted.org/packages/52/d1/43d17c299249085d6e0df36db272899e92aa09e68e27d3e92a4cf8d9523e/coverage-7.11.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f933bc1fead57373922e383d803e1dd5ec7b5a786c220161152ebee1aa3f006", size = 217170, upload-time = "2025-11-08T20:25:39.254Z" }, + { url = "https://files.pythonhosted.org/packages/78/66/f21c03307079a0b7867b364af057430018a3d4a18ed1b99e1adaf5a0f305/coverage-7.11.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f80cb5b328e870bf3df0568b41643a85ee4b8ccd219a096812389e39aa310ea4", size = 217497, upload-time = "2025-11-08T20:25:41.277Z" }, + { url = "https://files.pythonhosted.org/packages/f0/dd/0a2257154c32f442fe3b4622501ab818ae4bd7cde33bd7a740630f6bd24c/coverage-7.11.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6b2498f86f2554ed6cb8df64201ee95b8c70fb77064a8b2ae8a7185e7a4a5f0", size = 248539, upload-time = "2025-11-08T20:25:43.349Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ca/c55ab0ee5ebfc4ab56cfc1b3585cba707342dc3f891fe19f02e07bc0c25f/coverage-7.11.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a913b21f716aa05b149a8656e9e234d9da04bc1f9842136ad25a53172fecc20e", size = 251057, upload-time = "2025-11-08T20:25:45.083Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/a149b88ebe714b76d95427d609e629446d1df5d232f4bdaec34e471da124/coverage-7.11.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5769159986eb174f0f66d049a52da03f2d976ac1355679371f1269e83528599", size = 252393, upload-time = "2025-11-08T20:25:47.272Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a4/a992c805e95c46f0ac1b83782aa847030cb52bbfd8fc9015cff30f50fb9e/coverage-7.11.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89565d7c9340858424a5ca3223bfefe449aeb116942cdc98cd76c07ca50e9db8", size = 248534, upload-time = "2025-11-08T20:25:49.034Z" }, + { url = "https://files.pythonhosted.org/packages/78/01/318ed024ae245dbc76152bc016919aef69c508a5aac0e2da5de9b1efea61/coverage-7.11.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7fc943097fa48de00d14d2a2f3bcebfede024e031d7cd96063fe135f8cbe96e", size = 250412, upload-time = "2025-11-08T20:25:51.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/f05c7984ef48c8d1c6c1ddb243223b344dcd8c6c0d54d359e4e325e2fa7e/coverage-7.11.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:72a3d109ac233666064d60b29ae5801dd28bc51d1990e69f183a2b91b92d4baf", size = 248367, upload-time = "2025-11-08T20:25:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ac/461ed0dcaba0c727b760057ffa9837920d808a35274e179ff4a94f6f755a/coverage-7.11.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4648c90cf741fb61e142826db1557a44079de0ca868c5c5a363c53d852897e84", size = 248187, upload-time = "2025-11-08T20:25:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bf/8510ce8c7b1a8d682726df969e7523ee8aac23964b2c8301b8ce2400c1b4/coverage-7.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f1aa017b47e1879d7bac50161b00d2b886f2ff3882fa09427119e1b3572ede1", size = 249849, upload-time = "2025-11-08T20:25:57.186Z" }, + { url = "https://files.pythonhosted.org/packages/75/6f/ea1c8990ca35d607502c9e531f164573ea59bb6cd5cd4dc56d7cc3d1fcb5/coverage-7.11.2-cp314-cp314-win32.whl", hash = "sha256:44b6e04bb94e59927a2807cd4de86386ce34248eaea95d9f1049a72f81828c38", size = 219908, upload-time = "2025-11-08T20:25:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/1e/04/a64e2a8b9b65ae84670207dc6073e3d48ee9192646440b469e9b8c335d1f/coverage-7.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:7ea36e981a8a591acdaa920704f8dc798f9fff356c97dbd5d5702046ae967ce1", size = 220724, upload-time = "2025-11-08T20:26:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/73/df/eb4e9f9d0d55f7ec2b55298c30931a665c2249c06e3d1d14c5a6df638c77/coverage-7.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:4aaf2212302b6f748dde596424b0f08bc3e1285192104e2480f43d56b6824f35", size = 219296, upload-time = "2025-11-08T20:26:02.918Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b5/e9bb3b17a65fe92d1c7a2363eb5ae9893fafa578f012752ed40eee6aa3c8/coverage-7.11.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84e8e0f5ab5134a2d32d4ebadc18b433dbbeddd0b73481f816333b1edd3ff1c8", size = 217905, upload-time = "2025-11-08T20:26:04.633Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/1f38dd0b63a9d82fb3c9d7fbe1c9dab26ae77e5b45e801d129664e039034/coverage-7.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5db683000ff6217273071c752bd6a1d341b6dc5d6aaa56678c53577a4e70e78a", size = 218172, upload-time = "2025-11-08T20:26:06.677Z" }, + { url = "https://files.pythonhosted.org/packages/fd/5d/2aeb513c6841270783b216478c6edc65b128c6889850c5f77568aa3a3098/coverage-7.11.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2970c03fefee2a5f1aebc91201a0706a7d0061cc71ab452bb5c5345b7174a349", size = 259537, upload-time = "2025-11-08T20:26:08.481Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/ddd9b22ec1b5c69cc579b149619c354f981aaaafc072b92574f2d3d6c267/coverage-7.11.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9f28b900d96d83e2ae855b68d5cf5a704fa0b5e618999133fd2fb3bbe35ecb1", size = 261648, upload-time = "2025-11-08T20:26:10.551Z" }, + { url = "https://files.pythonhosted.org/packages/29/e2/8743b7281decd3f73b964389fea18305584dd6ba96f0aff91b4880b50310/coverage-7.11.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8b9a7ebc6a29202fb095877fd8362aab09882894d1c950060c76d61fb116114", size = 264061, upload-time = "2025-11-08T20:26:12.306Z" }, + { url = "https://files.pythonhosted.org/packages/00/1b/46daea7c4349c4530c62383f45148cc878845374b7a632e3ac2769b2f26a/coverage-7.11.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f8f6bcaa7fe162460abb38f7a5dbfd7f47cfc51e2a0bf0d3ef9e51427298391", size = 258580, upload-time = "2025-11-08T20:26:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/d7/53/f9b1c2d921d585dd6499e05bd71420950cac4e800f71525eb3d2690944fe/coverage-7.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:461577af3f8ad4da244a55af66c0731b68540ce571dbdc02598b5ec9e7a09e73", size = 261526, upload-time = "2025-11-08T20:26:16.353Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/55acee453a71a71b08b05848d718ce6ac4559d051b4a2c407b0940aa72be/coverage-7.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5b284931d57389ec97a63fb1edf91c68ec369cee44bc40b37b5c3985ba0a2914", size = 259135, upload-time = "2025-11-08T20:26:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3f/cf1e0217efdebab257eb0f487215fe02ff2b6f914cea641b2016c33358e1/coverage-7.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2ca963994d28e44285dc104cf94b25d8a7fd0c6f87cf944f46a23f473910703f", size = 257959, upload-time = "2025-11-08T20:26:19.894Z" }, + { url = "https://files.pythonhosted.org/packages/68/0e/e9be33e55346e650c3218a313e888df80418415462c63bceaf4b31e36ab5/coverage-7.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7d3fccd5781c5d29ca0bd1ea272630f05cd40a71d419e7e6105c0991400eb14", size = 260290, upload-time = "2025-11-08T20:26:22.05Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1d/9e93937c2a9bd255bb5efeff8c5df1c8322e508371f76f21a58af0e36a31/coverage-7.11.2-cp314-cp314t-win32.whl", hash = "sha256:f633da28958f57b846e955d28661b2b323d8ae84668756e1eea64045414dbe34", size = 220691, upload-time = "2025-11-08T20:26:24.043Z" }, + { url = "https://files.pythonhosted.org/packages/bf/30/893b5a67e2914cf2be8e99c511b8084eaa8c0585e42d8b3cd78208f5f126/coverage-7.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:410cafc1aba1f7eb8c09823d5da381be30a2c9b3595758a4c176fcfc04732731", size = 221800, upload-time = "2025-11-08T20:26:26.24Z" }, + { url = "https://files.pythonhosted.org/packages/2b/8b/6d93448c494a35000cc97d8d5d9c9b3774fa2b0c0d5be55f16877f962d71/coverage-7.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:595c6bb2b565cc2d930ee634cae47fa959dfd24cc0e8ae4cf2b6e7e131e0d1f7", size = 219838, upload-time = "2025-11-08T20:26:28.479Z" }, + { url = "https://files.pythonhosted.org/packages/05/7a/99766a75c88e576f47c2d9a06416ff5d95be9b42faca5c37e1ab77c4cd1a/coverage-7.11.2-py3-none-any.whl", hash = "sha256:2442afabe9e83b881be083238bb7cf5afd4a10e47f29b6094470338d2336b33c", size = 208891, upload-time = "2025-11-08T20:26:30.739Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, +] + [[package]] name = "pydantic" version = "2.12.3" @@ -96,6 +307,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-subtests" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" }, +] + [[package]] name = "python-omnilogic-local" version = "0.19.0" @@ -111,15 +386,73 @@ cli = [ { name = "scapy" }, ] +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-subtests" }, + { name = "types-xmltodict" }, +] + [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.0.0,<8.4.0" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "scapy", marker = "extra == 'cli'", specifier = ">=2.6.1,<3.0.0" }, - { name = "xmltodict", specifier = ">=1.0.2,<2.0.0" }, + { name = "xmltodict", specifier = ">=1.0.1,<2.0.0" }, ] provides-extras = ["cli"] +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.18.2,<2.0.0" }, + { name = "pre-commit", specifier = ">=4.0.0,<5.0.0" }, + { name = "pytest", specifier = ">=8.0.0,<9.0.0" }, + { name = "pytest-asyncio", specifier = ">=1.2.0,<2.0.0" }, + { name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" }, + { name = "pytest-subtests", specifier = ">=0.15.0,<1.0.0" }, + { name = "types-xmltodict", specifier = ">=1.0.1,<2.0.0" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "scapy" version = "2.6.1" @@ -129,6 +462,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/34/8695b43af99d0c796e4b7933a0d7df8925f43a8abdd0ff0f6297beb4de3a/scapy-2.6.1-py3-none-any.whl", hash = "sha256:88a998572049b511a1f3e44f4aa7c62dd39c6ea2aa1bb58434f503956641789d", size = 2420670, upload-time = "2024-11-05T08:43:21.285Z" }, ] +[[package]] +name = "types-xmltodict" +version = "1.0.1.20250920" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/a7/a3bd65abc7ca906c4d658140c63ce1def95dcd25497726d0eed05c45d245/types_xmltodict-1.0.1.20250920.tar.gz", hash = "sha256:3a2a97b7c3247251d715452e7bd86b9f0567fb91e407164344c93390d9bbbe88", size = 8705, upload-time = "2025-09-20T02:45:14.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/de/fd8b3ad5ef409bdb796b70e66d46a795785ece05823a2fffc916d8e166ba/types_xmltodict-1.0.1.20250920-py3-none-any.whl", hash = "sha256:2acd1bd50e226f4939507165e5bbbd3a3f69718439ba31c142755ce353343ff3", size = 8380, upload-time = "2025-09-20T02:45:13.629Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -150,6 +492,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + [[package]] name = "xmltodict" version = "1.0.2" From 7b53a59fb2beb1656023abe972e3af69edf0580d Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:58:40 -0600 Subject: [PATCH 59/61] chore: more test cleanups --- .vscode/settings.json | 2 +- pyproject.toml | 34 +++++++++++++++++++++++++--------- tests/test_filter_pump.py | 20 ++++++++------------ uv.lock | 2 +- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 15de615..fd02a96 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,7 @@ "--cov-report=xml:coverage.xml", "tests" ], - // "mypy-type-checker.importStrategy": "fromEnvironment", + "mypy-type-checker.importStrategy": "fromEnvironment", "python.analysis.typeCheckingMode": "basic", "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", diff --git a/pyproject.toml b/pyproject.toml index 11e9d57..30d8c94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ authors = [ {name = "djtimca"}, {name = "garionphx"} ] -license = {text = "Apache-2.0"} +license-files = ["LICENSE"] dependencies = [ "pydantic >=2.0.0,<3.0.0", "click >=8.0.0,<8.4.0", @@ -24,6 +24,10 @@ cli = [ "scapy>=2.6.1,<3.0.0", ] +[build-system] +requires = ["uv_build>=0.9.8,<0.10.0"] +build-backend = "uv_build" + [dependency-groups] dev = [ "pre-commit>=4.0.0,<5.0.0", @@ -37,20 +41,15 @@ dev = [ [tool.mypy] python_version = "3.13" -# plugins = [ -# "pydantic.mypy" -# ] +plugins = [ + "pydantic.mypy" +] follow_imports = "silent" strict = true ignore_missing_imports = true disallow_subclassing_any = false warn_return_any = false -[tool.pylint."FORMAT"] -expected-line-ending-format = "LF" -# Maximum number of characters on a single line. -max-line-length=140 - [tool.ruff] line-length = 140 @@ -99,3 +98,20 @@ future-annotations = true [tool.ruff.lint.pydocstyle] convention = "google" + +[tool.semantic_release] +branch = "main" +version_toml = ["pyproject.toml:project.version"] +build_command = "uv build" + +[tool.semantic_release.branches.refactor_abstracted_interface] +match = "refactor_abstracted_interface" +# prerelease = true +# prerelease_token = "alpha" + +[tool.uv] +package = true + +[tool.uv.build-backend] +module-name = "pyomnilogic_local" +module-root = "" diff --git a/tests/test_filter_pump.py b/tests/test_filter_pump.py index b048b6b..93e411d 100644 --- a/tests/test_filter_pump.py +++ b/tests/test_filter_pump.py @@ -31,9 +31,8 @@ def mock_omni() -> Mock: @pytest.fixture def sample_filter_config() -> MSPFilter: """Create a sample filter configuration.""" - return MSPFilter.model_construct( - None, - **{ + return MSPFilter.model_validate( + { "System-Id": 8, "Name": "Test Filter", "Filter-Type": FilterType.VARIABLE_SPEED, @@ -52,9 +51,8 @@ def sample_filter_config() -> MSPFilter: @pytest.fixture def sample_filter_telemetry() -> TelemetryFilter: """Create sample filter telemetry.""" - return TelemetryFilter.model_construct( - None, - **{ + return TelemetryFilter.model_validate( + { "@systemId": 8, "@filterState": FilterState.ON, "@filterSpeed": 60, @@ -70,9 +68,8 @@ def sample_filter_telemetry() -> TelemetryFilter: @pytest.fixture def sample_pump_config() -> MSPPump: """Create a sample pump configuration.""" - return MSPPump.model_construct( - None, - **{ + return MSPPump.model_validate( + { "System-Id": 15, "Name": "Test Pump", "Type": PumpType.VARIABLE_SPEED, @@ -92,9 +89,8 @@ def sample_pump_config() -> MSPPump: @pytest.fixture def sample_pump_telemetry() -> TelemetryPump: """Create sample pump telemetry.""" - return TelemetryPump.model_construct( - None, - **{ + return TelemetryPump.model_validate( + { "@systemId": 15, "@pumpState": PumpState.ON, "@pumpSpeed": 60, diff --git a/uv.lock b/uv.lock index 161804d..96c0dd6 100644 --- a/uv.lock +++ b/uv.lock @@ -374,7 +374,7 @@ wheels = [ [[package]] name = "python-omnilogic-local" version = "0.19.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "click" }, { name = "pydantic" }, From 636382fb1af9c23d9710932cdff52725733e7b85 Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 8 Nov 2025 21:47:03 -0600 Subject: [PATCH 60/61] ci: update python-semantic-release and associated actions workflows --- .github/workflows/cd.yaml | 140 ++++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 36 ++-------- pyproject.toml | 6 +- 3 files changed, 145 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/cd.yaml diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml new file mode 100644 index 0000000..d1cc3ad --- /dev/null +++ b/.github/workflows/cd.yaml @@ -0,0 +1,140 @@ +name: Continuous Delivery + +on: + workflow_run: + workflows: + - Continuous Integration + types: + - completed + branches: + - main + +# default: least privileged permissions across all jobs +permissions: + contents: read + +jobs: + check_success: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Workflow completed successfully + run: echo "CI workflow passed. Proceeding with CD." + + release: + runs-on: ubuntu-latest + needs: check_success + concurrency: + group: ${{ github.workflow }}-release-${{ github.ref_name }} + cancel-in-progress: false + + permissions: + contents: write + + steps: + # Note: We checkout the repository at the branch that triggered the workflow + # with the entire history to ensure to match PSR's release branch detection + # and history evaluation. + # However, we forcefully reset the branch to the workflow sha because it is + # possible that the branch was updated while the workflow was running. This + # prevents accidentally releasing un-evaluated changes. + - name: Setup | Checkout Repository on Release Branch + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Setup | Force release branch to be at workflow sha + run: | + git reset --hard ${{ github.sha }} + + - name: Evaluate | Verify upstream has NOT changed + # Last chance to abort before causing an error as another PR/push was applied to + # the upstream branch while this workflow was running. This is important + # because we are committing a version change (--commit). You may omit this step + # if you have 'commit: false' in your configuration. + # + # You may consider moving this to a repo script and call it from this step instead + # of writing it in-line. + shell: bash + run: | + set +o pipefail + + UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | awk -F '\\.\\.\\.' '{print $2}' | cut -d ' ' -f1)" + printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME" + + set -o pipefail + + if [ -z "$UPSTREAM_BRANCH_NAME" ]; then + printf >&2 '%s\n' "::error::Unable to determine upstream branch name!" + exit 1 + fi + + git fetch "${UPSTREAM_BRANCH_NAME%%/*}" + + if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then + printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!" + exit 1 + fi + + HEAD_SHA="$(git rev-parse HEAD)" + + if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then + printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]" + printf >&2 '%s\n' "::error::Upstream has changed, aborting release..." + exit 1 + fi + + printf '%s\n' "Verified upstream branch has not changed, continuing with release..." + + - name: Action | Semantic Version Release + id: release + # Adjust tag with desired version if applicable. + uses: python-semantic-release/python-semantic-release@v10.4.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + git_committer_name: "github-actions" + git_committer_email: "actions@users.noreply.github.com" + + - name: Publish | Upload to GitHub Release Assets + uses: python-semantic-release/publish-action@v10.4.1 + if: steps.release.outputs.released == 'true' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ steps.release.outputs.tag }} + + - name: Upload | Distribution Artifacts + uses: actions/upload-artifact@v4 + with: + name: distribution-artifacts + path: dist + if-no-files-found: error + + deploy: + # 1. Separate out the deploy step from the publish step to run each step at + # the least amount of token privilege + # 2. Also, deployments can fail, and its better to have a separate job if you need to retry + # and it won't require reversing the release. + runs-on: ubuntu-latest + needs: release + if: ${{ needs.release.outputs.released == 'true' }} + + environment: release + permissions: + contents: read + id-token: write + + steps: + - name: Setup | Download Build Artifacts + uses: actions/download-artifact@v4 + id: artifact-download + with: + name: distribution-artifacts + path: dist + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@v1.13.0 + with: + packages-dir: dist + print-hash: true + verbose: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83dc3e5..afb99bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,12 @@ -name: CI +name: Continuous Integration on: + pull_request: + branches: + - main push: branches: - main - pull_request: workflow_dispatch: concurrency: @@ -72,33 +74,3 @@ jobs: - name: Test with Pytest run: uv run pytest shell: bash - release: - runs-on: ubuntu-latest - environment: release - if: github.ref == 'refs/heads/main' - needs: - - test - - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.13" - - # Run semantic release: - # - Update CHANGELOG.md - # - Update version in code - # - Create git tag - # - Create GitHub release - # - Publish to PyPI - - name: Python Semantic Release - uses: relekang/python-semantic-release@v7.34.6 - with: - github_token: ${{ secrets.GH_TOKEN }} - repository_username: __token__ - repository_password: ${{ secrets.REPOSITORY_PASSWORD }} diff --git a/pyproject.toml b/pyproject.toml index 30d8c94..a37a1aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,11 +103,7 @@ convention = "google" branch = "main" version_toml = ["pyproject.toml:project.version"] build_command = "uv build" - -[tool.semantic_release.branches.refactor_abstracted_interface] -match = "refactor_abstracted_interface" -# prerelease = true -# prerelease_token = "alpha" +allow_zero_version = true [tool.uv] package = true From 00aecfd11a540aeb10181fda164a04e3a526c7ef Mon Sep 17 00:00:00 2001 From: Chris Jowett <421501+cryptk@users.noreply.github.com> Date: Sat, 8 Nov 2025 22:02:23 -0600 Subject: [PATCH 61/61] ci: add workflow to build and publish docker image to GHCR --- .github/workflows/cd-docker.yml | 280 ++++++++++++++++++ .../workflows/{cd.yaml => cd-release.yaml} | 2 +- .github/workflows/{ci.yml => ci-testing.yml} | 4 +- 3 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/cd-docker.yml rename .github/workflows/{cd.yaml => cd-release.yaml} (99%) rename .github/workflows/{ci.yml => ci-testing.yml} (98%) diff --git a/.github/workflows/cd-docker.yml b/.github/workflows/cd-docker.yml new file mode 100644 index 0000000..f13c4ab --- /dev/null +++ b/.github/workflows/cd-docker.yml @@ -0,0 +1,280 @@ +# This workflow builds a multi-arch Docker image using GitHub Actions and separated Github Runners with native support for ARM64 and AMD64 architectures, without using QEMU emulation. +# It uses Docker Buildx to build and push the image to GitHub Container Registry (GHCR). +name: CD Build and publish multi arch Docker Image + +on: + workflow_dispatch: + workflow_run: + workflows: + - Continuous Delivery + types: + - completed + branches: + - main + +env: + # The name of the Docker image to be built and pushed to GHCR + # The image name is derived from the GitHub repository name and the GitHub Container Registry (GHCR) URL. + # The image name will be in the format: ghcr.io// + GHCR_IMAGE: ghcr.io/${{ github.repository }} + +permissions: + # Global permissions for the workflow, which can be overridden at the job level + contents: read + +concurrency: + # This concurrency group ensures that only one job in the group runs at a time. + # If a new job is triggered, the previous one will be canceled. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The build job builds the Docker image for each platform specified in the matrix. + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + platform_pair: linux-amd64 + - platform: linux/arm64 + platform_pair: linux-arm64 + # The matrix includes two platforms: linux/amd64 and linux/arm64. + # The build job will run for each platform in the matrix. + + permissions: + # Permissions for the build job, which can be overridden at the step level + # The permissions are set to allow the job to write to the GitHub Container Registry (GHCR) and read from the repository. + attestations: write + actions: read + checks: write + contents: write + deployments: none + id-token: write + issues: read + discussions: read + packages: write + pages: none + pull-requests: read + repository-projects: read + security-events: read + statuses: read + + runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }} + # The job runs on different runners based on the platform. + # For linux/amd64, it runs on the latest Ubuntu runner. + # For linux/arm64, it runs on an Ubuntu 24.04 ARM runner. + # The runner is selected based on the platform specified in the matrix. + + name: Build Docker image for ${{ matrix.platform }} + + steps: + - + name: Prepare environment for current platform + # This step sets up the environment for the current platform being built. + # It replaces the '/' character in the platform name with '-' and sets it as an environment variable. + # This is useful for naming artifacts and other resources that cannot contain '/'. + # The environment variable PLATFORMS_PAIR will be used later in the workflow. + id: prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@v5.0.0 + # This step checks out the code from the repository. + # It uses the actions/checkout action to clone the repository into the runner's workspace. + + - name: Docker meta default + # This step generates metadata for the Docker image. + # It uses the docker/metadata-action to create metadata based on the repository information. + # The metadata includes information such as the image name, tags, and labels. + # The metadata will be used later in the workflow to build and push the Docker image. + id: meta + uses: docker/metadata-action@v5.9.0 + with: + images: ${{ env.GHCR_IMAGE }} + + - name: Set up Docker Context for Buildx + # This step sets up a Docker context for Buildx. + # It creates a new context named "builders" that will be used for building the Docker image. + # The context allows Buildx to use the Docker daemon for building images. + id: buildx-context + run: | + docker context create builders + + - name: Set up Docker Buildx + # This step sets up Docker Buildx, which is a Docker CLI plugin for extended build capabilities with BuildKit. + # It uses the docker/setup-buildx-action to configure Buildx with the specified context and platforms. + # The platforms are specified in the matrix and will be used for building the Docker image. + uses: docker/setup-buildx-action@v3.11.1 + with: + endpoint: builders + platforms: ${{ matrix.platform }} + + - name: Login to GitHub Container Registry + # This step logs in to the GitHub Container Registry (GHCR) using the docker/login-action. + # It uses the GitHub actor's username and the GITHUB_TOKEN secret for authentication. + uses: docker/login-action@v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + + - name: Build and push by digest + # This step builds and pushes the Docker image using Buildx. + # It uses the docker/build-push-action to build the image with the specified context and platforms. + # The image is built with the labels and annotations generated in the previous steps. + # The outputs are configured to push the image by digest, which allows for better caching and versioning. + # The cache-from and cache-to options are used to enable caching for the build process. + # The cache is stored in GitHub Actions cache and is scoped to the repository, branch, and platform. + id: build + uses: docker/build-push-action@v6.18.0 + env: + DOCKER_BUILDKIT: 1 + with: + context: . + build-args: | + VERSION=${{ steps.meta.outputs.version }} + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true + cache-from: type=gha,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }} + cache-to: type=gha,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }} + + + - name: Export digest + # This step exports the digest of the built image to a file. + # It creates a directory in /tmp/digests and saves the digest of the image to a file. + # The digest is obtained from the output of the build step. + # The digest is used to uniquely identify the built image and can be used for further processing or verification. + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + # This step uploads the digest file to the GitHub Actions artifact storage. + # It uses the actions/upload-artifact action to upload the file created in the previous step. + # The artifact is named digests-${{ matrix.platform_pair }}, where platform_pair is the platform name with '/' replaced by '-'. + # The artifact is retained for 1 day, and if no files are found, it will throw an error. + uses: actions/upload-artifact@v5.0.0 + with: + name: digests-${{ matrix.platform_pair }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + + merge: + # This job merges the Docker manifests for the different platforms built in the previous job. + name: Merge Docker manifests + runs-on: ubuntu-latest + permissions: + attestations: write + actions: read + checks: read + contents: read + deployments: none + id-token: write + issues: read + discussions: read + packages: write + pages: none + pull-requests: read + repository-projects: read + security-events: read + statuses: read + + needs: + - build + # This job depends on the build job to complete before it starts. + # It ensures that the Docker images for all platforms are built before merging the manifests. + steps: + - name: Download digests + # This step downloads the digest files uploaded in the build job. + # It uses the actions/download-artifact action to download the artifacts with the pattern digests-*. + # The downloaded files are merged into the /tmp/digests directory. + uses: actions/download-artifact@v6.0.0 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + + - name: Docker meta + # This step generates metadata for the Docker image. + # It uses the docker/metadata-action to create metadata based on the repository information. + # The metadata includes information such as the image name, tags, and labels. + id: meta + uses: docker/metadata-action@v5.9.0 + with: + images: ${{ env.GHCR_IMAGE }} + annotations: | + type=org.opencontainers.image.description,value=${{ github.event.repository.description || 'No description provided' }} + tags: | + type=ref,event=tag + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.11.1 + # This step sets up Docker Buildx, which is a Docker CLI plugin for extended build capabilities with BuildKit. + with: + driver-opts: | + network=host + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3.6.0 + # This step logs in to the GitHub Container Registry (GHCR) using the docker/login-action. + # It uses the GitHub actor's username and the GITHUB_TOKEN secret for authentication. + # The login is necessary to push the merged manifest list to GHCR. + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Get execution timestamp with RFC3339 format + # This step gets the current execution timestamp in RFC3339 format. + # It uses the date command to get the current UTC time and formats it as a string. + # The timestamp is used for annotating the Docker manifest list. + id: timestamp + run: | + echo "timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> $GITHUB_OUTPUT + + - name: Create manifest list and pushs + # This step creates a manifest list for the Docker images built for different platforms. + # It uses the docker buildx imagetools create command to create the manifest list. + # The manifest list is annotated with metadata such as description, creation timestamp, and source URL. + # The annotations are obtained from the metadata generated in the previous steps. + # The manifest list is pushed to the GitHub Container Registry (GHCR) with the specified tags. + working-directory: /tmp/digests + id: manifest-annotate + continue-on-error: true + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + --annotation='index:org.opencontainers.image.description=${{ github.event.repository.description }}' \ + --annotation='index:org.opencontainers.image.created=${{ steps.timestamp.outputs.timestamp }}' \ + --annotation='index:org.opencontainers.image.url=${{ github.event.repository.url }}' \ + --annotation='index:org.opencontainers.image.source=${{ github.event.repository.url }}' \ + $(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *) + + - name: Create manifest list and push without annotations + # This step creates a manifest list for the Docker images built for different platforms. + # It uses the docker buildx imagetools create command to create the manifest list. + # The manifest list is created without annotations if the previous step fails. + # The manifest list is pushed to the GitHub Container Registry (GHCR) with the specified tags. + if: steps.manifest-annotate.outcome == 'failure' + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *) + + - name: Inspect image + # This step inspects the created manifest list to verify its contents. + # It uses the docker buildx imagetools inspect command to display information about the manifest list. + # The inspection output will show the platforms and tags associated with the manifest list. + id: inspect + run: | + docker buildx imagetools inspect '${{ env.GHCR_IMAGE }}:${{ steps.meta.outputs.version }}' diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd-release.yaml similarity index 99% rename from .github/workflows/cd.yaml rename to .github/workflows/cd-release.yaml index d1cc3ad..ec82eda 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd-release.yaml @@ -1,4 +1,4 @@ -name: Continuous Delivery +name: CD Release and Publish on: workflow_run: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci-testing.yml similarity index 98% rename from .github/workflows/ci.yml rename to .github/workflows/ci-testing.yml index afb99bc..7bc46f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci-testing.yml @@ -1,13 +1,13 @@ -name: Continuous Integration +name: CI Testing Workflow on: + workflow_dispatch: pull_request: branches: - main push: branches: - main - workflow_dispatch: concurrency: group: ${{ github.head_ref || github.run_id }}