diff --git a/README.md b/README.md index 55b73fe..648eec2 100644 --- a/README.md +++ b/README.md @@ -289,8 +289,8 @@ omnilogic get heaters # Get raw XML responses omnilogic debug --raw get-mspconfig -# View filter diagnostics -omnilogic debug get-filter-diagnostics +# View filter/pump diagnostics (works for both filter pumps and auxiliary VSP pumps) +omnilogic debug get-filter-pump-diagnostics ``` **Installation with CLI tools**: diff --git a/pyomnilogic_local/cli/debug/commands.py b/pyomnilogic_local/cli/debug/commands.py index b21c53f..50fd0fa 100644 --- a/pyomnilogic_local/cli/debug/commands.py +++ b/pyomnilogic_local/cli/debug/commands.py @@ -17,10 +17,20 @@ if TYPE_CHECKING: from pyomnilogic_local import OmniLogic + from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics from pyomnilogic_local.models.telemetry import TelemetryChlorinator from pyomnilogic_local.omnitypes import MessageType +def _echo_diagnostics_summary(diagnostics: "FilterDiagnostics", bow_id: int, equip_id: int) -> None: + click.echo(f"PoolID: {bow_id}") + click.echo(f"EquipmentID: {equip_id}") + click.echo(f"Power: {diagnostics.power_watts} W") + click.echo(f"Drive Rev: {diagnostics.drive_firmware_revision or 'Unknown'}") + click.echo(f"Display Rev: {diagnostics.display_firmware_revision or 'Unknown'}") + click.echo(f"Error: {diagnostics.error_summary}") + + @click.group() @click.option("--raw/--no-raw", default=False, help="Output the raw XML from the OmniLogic, do not parse the response") @click.pass_context @@ -75,20 +85,24 @@ def get_telemetry(ctx: click.Context) -> None: @click.argument("bow_id", type=int) @click.argument("equip_id", type=int) @click.pass_context -def get_filter_diagnostics(ctx: click.Context, bow_id: int, equip_id: int) -> None: - """Retrieve current filter diagnostics from the controller. +def get_filter_pump_diagnostics(ctx: click.Context, bow_id: int, equip_id: int) -> None: + """Retrieve current filter/pump diagnostics from the controller. - Filter diagnostics include real-time sensor readings, equipment states, temperatures, - and other operational data. Use --raw to see the unprocessed XML. + Filter and VSP pump diagnostics use the same underlying OmniLogic request type. + This command works for both filter pumps and auxiliary VSP pumps. Example: - omnilogic debug get-filter-diagnostics - omnilogic debug --raw get-filter-diagnostics + omnilogic debug get-filter-pump-diagnostics 1 3 + omnilogic debug --raw get-filter-pump-diagnostics 1 3 """ omnilogic: OmniLogic = ctx.obj["OMNILOGIC"] - telemetry = asyncio.run(omnilogic._api.async_get_filter_diagnostics(pool_id=bow_id, equipment_id=equip_id, raw=ctx.obj["RAW"])) - click.echo(telemetry) + diagnostics = asyncio.run(omnilogic._api.async_get_filter_diagnostics(pool_id=bow_id, equipment_id=equip_id, raw=ctx.obj["RAW"])) + if ctx.obj["RAW"]: + click.echo(diagnostics) + return + + _echo_diagnostics_summary(diagnostics, bow_id, equip_id) @debug.command() diff --git a/pyomnilogic_local/models/filter_diagnostics.py b/pyomnilogic_local/models/filter_diagnostics.py index c582b9c..d6fc5ec 100644 --- a/pyomnilogic_local/models/filter_diagnostics.py +++ b/pyomnilogic_local/models/filter_diagnostics.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re + from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationError from xmltodict import parse as xml_parse @@ -56,6 +58,56 @@ class FilterDiagnostics(BaseModel): def get_param_by_name(self, name: str) -> int: return next(param.value for param in self.parameters if param.name == name) + def _decode_revision(self, prefix: str) -> str: + bytes_ = [] + for index in range(1, 7): + param_name = f"{prefix}B{index}" + try: + byte_val = self.get_param_by_name(param_name) + except StopIteration: + break + if byte_val == 0: + break + bytes_.append(byte_val) + if not bytes_: + return "" + raw = bytes(bytes_).decode("ascii", errors="ignore").strip() + if re.fullmatch(r"\d{4}", raw): + return f"{raw[:2]}.{raw[2]}.{raw[3]}" + + compact = raw.lstrip("0") or raw + if re.fullmatch(r"\d{2}[A-Za-z]", compact): + return f"{compact[0]}.{compact[1:]}" + + return raw + + @property + def power_watts(self) -> int: + """Current power draw in watts computed from MSB/LSB fields.""" + lsb = self.get_param_by_name("PowerLSB") + msb = self.get_param_by_name("PowerMSB") + return (msb << 8) | lsb + + @property + def drive_firmware_revision(self) -> str: + """Drive firmware revision string, if present in diagnostics payload.""" + return self._decode_revision("DriveFWRevision") + + @property + def display_firmware_revision(self) -> str: + """Display firmware revision string, if present in diagnostics payload.""" + return self._decode_revision("DisplayFWRevision") + + @property + def error_status(self) -> int: + """Raw error status code reported by controller diagnostics.""" + return self.get_param_by_name("ErrorStatus") + + @property + def error_summary(self) -> str: + """Friendly error summary for diagnostics.""" + return "No errors detected" if self.error_status == 0 else f"Error status code {self.error_status}" + @staticmethod def load_xml(xml: str) -> FilterDiagnostics: data = xml_parse( diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index 987f135..86e1804 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -374,6 +374,7 @@ class TelemetryPump(BaseModel): Fields: state: Current pump state (OFF, ON, FREEZE_PROTECT) speed: Current speed setting (percentage 0-100 or RPM depending on type) + power: Current power consumption in watts last_speed: Previous speed setting before state change why_on: Reason pump is running (usage similar to FilterWhyOn) """ @@ -384,6 +385,7 @@ class TelemetryPump(BaseModel): system_id: int = Field(alias="@systemId") state: PumpState = Field(alias="@pumpState") speed: int = Field(alias="@pumpSpeed") + power: int = Field(alias="@power", default=0) last_speed: int = Field(alias="@lastSpeed") why_on: PumpWhyOn = Field(alias="@whyOn") diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py index 783e732..7cd7f79 100644 --- a/pyomnilogic_local/pump.py +++ b/pyomnilogic_local/pump.py @@ -42,6 +42,7 @@ class Pump(OmniEquipment[MSPPump, TelemetryPump]): Properties (Telemetry): state: Current operational state (OFF, ON) speed: Current operating speed + power: Current power consumption in watts last_speed: Previous speed setting why_on: Reason code for pump being on @@ -145,6 +146,11 @@ def speed(self) -> int: """Current pump speed.""" return self.telemetry.speed + @property + def power(self) -> int: + """Current power consumption.""" + return self.telemetry.power + @property def last_speed(self) -> int: """Last speed setting.""" diff --git a/tests/test_api.py b/tests/test_api.py index ea94359..7573b99 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -26,6 +26,8 @@ XML_NAMESPACE, ) from pyomnilogic_local.api.exceptions import OmniValidationError +from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics +from pyomnilogic_local.models.telemetry import TelemetryPump from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicShow40, ColorLogicSpeed, HeaterMode, MessageType # ============================================================================ @@ -223,11 +225,11 @@ async def test_async_get_telemetry_generates_valid_xml() -> None: @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.""" + """Filter diagnostics request should generate valid XML with correct parameters.""" api = OmniLogicAPI("192.168.1.100") with patch.object(api, "async_send_and_receive", new_callable=AsyncMock) as mock_send: - mock_send.return_value = 'FilterDiagnostics' + mock_send.return_value = 'Diagnostics' await api.async_get_filter_diagnostics(pool_id=1, equipment_id=2, raw=True) @@ -243,6 +245,53 @@ async def test_async_get_filter_diagnostics_generates_valid_xml() -> None: assert _find_param(root, "equipmentId").text == "2" +def test_filter_diagnostics_computed_fields() -> None: + """Power and revision fields should be decoded from diagnostics payload.""" + xml = """ + + GetUIFilterDiagnosticInfoRsp + + 1 + 9 + 29 + 2 + 0 + 49 + 48 + 46 + 49 + 46 + 55 + 49 + 46 + 48 + 65 + 0 + +""" + + diagnostics = FilterDiagnostics.load_xml(xml) + assert diagnostics.power_watts == 541 + assert diagnostics.display_firmware_revision == "10.1.7" + assert diagnostics.drive_firmware_revision == "1.0A" + assert diagnostics.error_summary == "No errors detected" + + +def test_telemetry_pump_parses_power() -> None: + """TelemetryPump should parse power when present.""" + telemetry = TelemetryPump.model_validate( + { + "@systemId": "9", + "@pumpState": "1", + "@pumpSpeed": "70", + "@power": "541", + "@lastSpeed": "70", + "@whyOn": "0", + } + ) + assert telemetry.power == 541 + + @pytest.mark.asyncio async def test_async_set_heater_generates_valid_xml() -> None: """Test that async_set_heater generates valid XML with correct parameters."""