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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,10 @@ omnilogic get heaters
omnilogic debug --raw get-mspconfig

# View filter diagnostics
omnilogic debug get-filter-diagnostics
omnilogic debug get-filter-diagnostics <bow_id> <equip_id>

# View VSP pump diagnostics
omnilogic debug get-pump-diagnostics <bow_id> <equip_id>
```

**Installation with CLI tools**:
Expand Down
17 changes: 17 additions & 0 deletions pyomnilogic_local/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,23 @@ async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, ra
return resp
return FilterDiagnostics.load_xml(resp)

async def async_get_pump_diagnostics(self, pool_id: int, equipment_id: int, raw: bool = False) -> FilterDiagnostics | str:
"""Retrieve diagnostics for a VSP pump.

OmniLogic uses the same underlying request type for both filter and auxiliary
VSP pump diagnostics. This helper exists to make pump diagnostics discoverable
without requiring users to call a filter-named API method.

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 FilterDiagnostics object or a raw XML string.
"""
return await self.async_get_filter_diagnostics(pool_id=pool_id, equipment_id=equipment_id, raw=raw)

@overload
async def async_get_telemetry(self, raw: Literal[True]) -> str: ...
@overload
Expand Down
43 changes: 41 additions & 2 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 @@ -87,8 +97,37 @@ def get_filter_diagnostics(ctx: click.Context, bow_id: int, equip_id: int) -> No

"""
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()
@click.argument("bow_id", type=int)
@click.argument("equip_id", type=int)
@click.pass_context
def get_pump_diagnostics(ctx: click.Context, bow_id: int, equip_id: int) -> None:
"""Retrieve current VSP pump diagnostics from the controller.

Pump diagnostics use the same OmniLogic request type as filter diagnostics,
but this command is provided so pump diagnostics are discoverable under pump
workflows.

Example:
omnilogic debug get-pump-diagnostics 1 9
omnilogic debug --raw get-pump-diagnostics 1 9

"""
omnilogic: OmniLogic = ctx.obj["OMNILOGIC"]
diagnostics = asyncio.run(omnilogic._api.async_get_pump_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 @@ -370,6 +370,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 @@ -380,6 +381,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: int = 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
64 changes: 59 additions & 5 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 @@ -222,14 +224,19 @@ 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."""
@pytest.mark.parametrize(
("method_name", "equipment_id"),
[("async_get_filter_diagnostics", 2), ("async_get_pump_diagnostics", 9)],
)
async def test_async_get_diagnostics_generates_valid_xml(method_name: str, equipment_id: int) -> None:
"""Both diagnostics helpers 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)
method = getattr(api, method_name)
await method(pool_id=1, equipment_id=equipment_id, raw=True)

mock_send.assert_called_once()
call_args = mock_send.call_args
Expand All @@ -240,7 +247,54 @@ async def test_async_get_filter_diagnostics_generates_valid_xml() -> None:
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"
assert _find_param(root, "equipmentId").text == str(equipment_id)


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