Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bow_id> <equip_id>
```

**Installation with CLI tools**:
Expand Down
30 changes: 22 additions & 8 deletions pyomnilogic_local/cli/debug/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
52 changes: 52 additions & 0 deletions pyomnilogic_local/models/filter_diagnostics.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions pyomnilogic_local/models/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand All @@ -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")

Expand Down
6 changes: 6 additions & 0 deletions pyomnilogic_local/pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down
53 changes: 51 additions & 2 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ============================================================================
Expand Down Expand Up @@ -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 = '<?xml version="1.0"?><Response><Name>FilterDiagnostics</Name></Response>'
mock_send.return_value = '<?xml version="1.0"?><Response><Name>Diagnostics</Name></Response>'

await api.async_get_filter_diagnostics(pool_id=1, equipment_id=2, raw=True)

Expand All @@ -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 = """<?xml version="1.0" encoding="UTF-8" ?>
<Response xmlns="http://nextgen.hayward.com/api">
<Name>GetUIFilterDiagnosticInfoRsp</Name>
<Parameters>
<Parameter name="PoolID" dataType="int">1</Parameter>
<Parameter name="EquipmentID" dataType="int">9</Parameter>
<Parameter name="PowerLSB" dataType="byte">29</Parameter>
<Parameter name="PowerMSB" dataType="byte">2</Parameter>
<Parameter name="ErrorStatus" dataType="byte">0</Parameter>
<Parameter name="DisplayFWRevisionB1" dataType="byte">49</Parameter>
<Parameter name="DisplayFWRevisionB2" dataType="byte">48</Parameter>
<Parameter name="DisplayFWRevisionB3" dataType="byte">46</Parameter>
<Parameter name="DisplayFWRevisionB4" dataType="byte">49</Parameter>
<Parameter name="DisplayFWRevisionB5" dataType="byte">46</Parameter>
<Parameter name="DisplayFWRevisionB6" dataType="byte">55</Parameter>
<Parameter name="DriveFWRevisionB1" dataType="byte">49</Parameter>
<Parameter name="DriveFWRevisionB2" dataType="byte">46</Parameter>
<Parameter name="DriveFWRevisionB3" dataType="byte">48</Parameter>
<Parameter name="DriveFWRevisionB4" dataType="byte">65</Parameter>
<Parameter name="DriveFWRevisionB5" dataType="byte">0</Parameter>
</Parameters>
</Response>"""

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."""
Expand Down