Skip to content

Commit 7a37b1d

Browse files
committed
feat: migrate filter diags and leadmessage to pydantic-xml
1 parent 24d7c5d commit 7a37b1d

5 files changed

Lines changed: 131 additions & 54 deletions

File tree

pyomnilogic_local/api/protocol.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ async def _receive_file(self) -> str:
363363
# If the response is too large, the controller will send a LeadMessage indicating how many follow-up messages will be sent
364364
if message.type is MessageType.MSP_LEADMESSAGE:
365365
try:
366-
leadmsg = LeadMessage.model_validate(ET.fromstring(message.payload[:-1]))
366+
leadmsg = LeadMessage.from_xml(message.payload[:-1])
367367
except Exception as exc:
368368
raise OmniFragmentationException(f"Failed to parse LeadMessage: {exc}") from exc
369369

pyomnilogic_local/cli/pcap_utils.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from __future__ import annotations
99

10-
import xml.etree.ElementTree as ET
1110
import zlib
1211
from collections import defaultdict
1312
from typing import Any
@@ -147,7 +146,7 @@ def process_pcap_messages(packets: Any) -> list[tuple[str, str, OmniLogicMessage
147146

148147
# Check if we have all the blocks
149148
lead_msg = message_sequences[matching_seq][0]
150-
lead_data = LeadMessage.model_validate(ET.fromstring(lead_msg.payload[:-1]))
149+
lead_data = LeadMessage.from_xml(lead_msg.payload[:-1])
151150

152151
# We have LeadMessage + all BlockMessages
153152
if len(message_sequences[matching_seq]) == lead_data.msg_block_count + 1:
Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,87 @@
11
from __future__ import annotations
22

3-
from pydantic import BaseModel, ConfigDict, Field
4-
from xmltodict import parse as xml_parse
3+
from pydantic import ConfigDict
4+
from pydantic_xml import BaseXmlModel, attr, element, wrapped
55

6+
from .const import XML_NS
67

7-
class FilterDiagnosticsParameter(BaseModel):
8-
model_config = ConfigDict(from_attributes=True)
8+
# Example Filter Diagnostics XML:
9+
#
10+
# <?xml version="1.0" encoding="UTF-8" ?>
11+
# <Response xmlns="http://nextgen.hayward.com/api">
12+
# <Name>GetUIFilterDiagnosticInfoRsp</Name>
13+
# <Parameters>
14+
# <Parameter name="PoolID" dataType="int">7</Parameter>
15+
# <Parameter name="EquipmentID" dataType="int">8</Parameter>
16+
# <Parameter name="PowerLSB" dataType="byte">133</Parameter>
17+
# <Parameter name="PowerMSB" dataType="byte">4</Parameter>
18+
# <Parameter name="ErrorStatus" dataType="byte">0</Parameter>
19+
# <Parameter name="DisplayFWRevisionB1" dataType="byte">49</Parameter>
20+
# <Parameter name="DisplayFWRevisionB2" dataType="byte">48</Parameter>
21+
# <Parameter name="DisplayFWRevisionB3" dataType="byte">49</Parameter>
22+
# <Parameter name="DisplayFWRevisionB4" dataType="byte">53</Parameter>
23+
# <Parameter name="DisplayFWRevisionB5" dataType="byte">32</Parameter>
24+
# <Parameter name="DisplayFWRevisionB6" dataType="byte">0</Parameter>
25+
# <Parameter name="DriveFWRevisionB1" dataType="byte">48</Parameter>
26+
# <Parameter name="DriveFWRevisionB2" dataType="byte">48</Parameter>
27+
# <Parameter name="DriveFWRevisionB3" dataType="byte">55</Parameter>
28+
# <Parameter name="DriveFWRevisionB4" dataType="byte">48</Parameter>
29+
# <Parameter name="DriveFWRevisionB5" dataType="byte">32</Parameter>
30+
# <Parameter name="DriveFWRevisionB6" dataType="byte">0</Parameter>
31+
# </Parameters>
32+
# </Response>
933

10-
name: str = Field(alias="@name")
11-
dataType: str = Field(alias="@dataType")
12-
value: int = Field(alias="#text")
1334

35+
class FilterDiagnosticsParameter(BaseXmlModel, tag="Parameter", ns="api", nsmap=XML_NS):
36+
"""Individual diagnostic parameter with name, type, and value."""
1437

15-
class FilterDiagnosticsParameters(BaseModel):
1638
model_config = ConfigDict(from_attributes=True)
1739

18-
parameter: list[FilterDiagnosticsParameter] = Field(alias="Parameter")
40+
name: str = attr()
41+
data_type: str = attr(name="dataType")
42+
value: int
43+
1944

45+
class FilterDiagnostics(BaseXmlModel, tag="Response", ns="api", nsmap=XML_NS):
46+
"""Filter diagnostics response containing diagnostic parameters.
47+
48+
The XML structure has a Parameters wrapper element containing Parameter children:
49+
<Response>
50+
<Name>FilterDiagnostics</Name>
51+
<Parameters>
52+
<Parameter name="..." dataType="...">value</Parameter>
53+
...
54+
</Parameters>
55+
</Response>
56+
"""
2057

21-
class FilterDiagnostics(BaseModel):
2258
model_config = ConfigDict(from_attributes=True)
2359

24-
name: str = Field(alias="Name")
25-
# parameters: FilterDiagnosticsParameters = Field(alias="Parameters")
26-
parameters: list[FilterDiagnosticsParameter] = Field(alias="Parameters")
60+
name: str = element(tag="Name")
61+
parameters: list[FilterDiagnosticsParameter] = wrapped("Parameters", element(tag="Parameter", default_factory=list))
2762

2863
def get_param_by_name(self, name: str) -> int:
64+
"""Get parameter value by name.
65+
66+
Args:
67+
name: Name of the parameter to retrieve
68+
69+
Returns:
70+
The integer value of the parameter
71+
72+
Raises:
73+
IndexError: If parameter name not found
74+
"""
2975
return [param.value for param in self.parameters if param.name == name][0]
3076

3177
@staticmethod
3278
def load_xml(xml: str) -> FilterDiagnostics:
33-
data = xml_parse(
34-
xml,
35-
# 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
36-
# everything that *could* be a list into a list to make the parsing more consistent.
37-
force_list=("Parameter"),
38-
)
39-
# The XML nests the Parameter entries under a Parameters entry, this is annoying to work with. Here we are adjusting the data to
40-
# remove that extra level in the data
41-
data["Response"]["Parameters"] = data["Response"]["Parameters"]["Parameter"]
42-
return FilterDiagnostics.model_validate(data["Response"])
79+
"""Load filter diagnostics from XML string.
80+
81+
Args:
82+
xml: XML string containing filter diagnostics data
83+
84+
Returns:
85+
Parsed FilterDiagnostics instance
86+
"""
87+
return FilterDiagnostics.from_xml(xml)
Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,63 @@
11
from __future__ import annotations
22

3-
from typing import Any
4-
from xml.etree.ElementTree import Element
5-
6-
from pydantic import BaseModel, ConfigDict, Field, model_validator
3+
from pydantic import ConfigDict, computed_field
4+
from pydantic_xml import BaseXmlModel, attr, element, wrapped
75

86
from .const import XML_NS
97

8+
# Example Lead Message XML:
9+
#
10+
# <?xml.version="1.0" encoding="UTF-8"?>
11+
# <Response xmlns="http://nextgen.hayward.com/api">
12+
# <Name>LeadMessage</Name>
13+
# <Parameters>
14+
# <Parameter name="SourceOpId" dataType="int">1003</Parameter>
15+
# <Parameter name="MsgSize" dataType="int">3709</Parameter>
16+
# <Parameter name="MsgBlockCount" dataType="int">4</Parameter>
17+
# <Parameter name="Type" dataType="int">0</Parameter>
18+
# </Parameters>
19+
# </Response>
20+
21+
22+
class LeadMessageParameter(BaseXmlModel, tag="Parameter", ns="api", nsmap=XML_NS):
23+
"""Individual parameter in lead message."""
24+
25+
name: str = attr()
26+
value: int
27+
28+
29+
class LeadMessage(BaseXmlModel, tag="Response", ns="api", nsmap=XML_NS):
30+
"""Lead message containing protocol parameters.
31+
32+
Lead messages are sent at the start of communication to establish
33+
protocol parameters like message size and block count.
34+
"""
1035

11-
class LeadMessage(BaseModel):
1236
model_config = ConfigDict(from_attributes=True)
1337

14-
source_op_id: int = Field(alias="SourceOpId")
15-
msg_size: int = Field(alias="MsgSize")
16-
msg_block_count: int = Field(alias="MsgBlockCount")
17-
type: int = Field(alias="Type")
18-
19-
@model_validator(mode="before")
20-
@classmethod
21-
def parse_xml_element(cls, data: Any) -> dict[str, Any]:
22-
"""Parse XML Element into dict format for Pydantic validation."""
23-
if isinstance(data, Element):
24-
# Parse the Parameter elements from the XML
25-
result = {}
26-
for param in data.findall(".//api:Parameter", XML_NS):
27-
if name := param.get("name"):
28-
result[name] = int(param.text) if param.text else 0
29-
return result
30-
return data
38+
name: str = element(tag="Name")
39+
parameters: list[LeadMessageParameter] = wrapped("Parameters", element(tag="Parameter", default_factory=list))
40+
41+
@computed_field # type: ignore[prop-decorator]
42+
@property
43+
def source_op_id(self) -> int:
44+
"""Extract SourceOpId from parameters."""
45+
return next((p.value for p in self.parameters if p.name == "SourceOpId"), 0)
46+
47+
@computed_field # type: ignore[prop-decorator]
48+
@property
49+
def msg_size(self) -> int:
50+
"""Extract MsgSize from parameters."""
51+
return next((p.value for p in self.parameters if p.name == "MsgSize"), 0)
52+
53+
@computed_field # type: ignore[prop-decorator]
54+
@property
55+
def msg_block_count(self) -> int:
56+
"""Extract MsgBlockCount from parameters."""
57+
return next((p.value for p in self.parameters if p.name == "MsgBlockCount"), 0)
58+
59+
@computed_field # type: ignore[prop-decorator]
60+
@property
61+
def type(self) -> int:
62+
"""Extract Type from parameters."""
63+
return next((p.value for p in self.parameters if p.name == "Type"), 0)

tests/test_fixtures.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No
109109
pool = msp.backyard.bow[0]
110110
assert pool.system_id == 3
111111
assert pool.name == "Pool"
112-
assert pool.omni_type == OmniType.BOW_MSP
112+
assert pool.omni_type == OmniType.BOW
113113

114114
with subtests.test(msg="filter configuration"):
115115
filters = get_equipment_by_type(msp, OmniType.FILTER)
@@ -155,7 +155,7 @@ def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> No
155155
assert len(telem.virtual_heater) == 1
156156
vh = telem.virtual_heater[0]
157157
assert vh.system_id == 15
158-
assert vh.current_set_point == 82
158+
assert vh.current_set_point == 65
159159

160160
with subtests.test(msg="heater equipment telemetry"):
161161
assert telem.heater is not None
@@ -202,7 +202,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No
202202
pool = msp.backyard.bow[0]
203203
assert pool.system_id == 10
204204
assert pool.name == "Pool"
205-
assert pool.omni_type == OmniType.BOW_MSP
205+
assert pool.omni_type == OmniType.BOW
206206

207207
with subtests.test(msg="filter configuration"):
208208
filters = get_equipment_by_type(msp, OmniType.FILTER)
@@ -225,7 +225,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No
225225
with subtests.test(msg="backyard sensors"):
226226
assert msp.backyard.sensor is not None
227227
assert len(msp.backyard.sensor) == 1
228-
assert msp.backyard.sensor[0].system_id == 1
228+
assert msp.backyard.sensor[0].system_id == 16
229229

230230
def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None:
231231
"""Test Telemetry parsing for issue-163."""
@@ -299,7 +299,7 @@ def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> No
299299
pool = msp.backyard.bow[0]
300300
assert pool.system_id == 1
301301
assert pool.name == "Pool"
302-
assert pool.omni_type == OmniType.BOW_MSP
302+
assert pool.omni_type == OmniType.BOW
303303

304304
with subtests.test(msg="spa configuration"):
305305
assert msp.backyard.bow is not None
@@ -378,7 +378,7 @@ def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> No
378378

379379
with subtests.test(msg="relay telemetry"):
380380
assert telem.relay is not None
381-
assert len(telem.relay) == 2
381+
assert len(telem.relay) == 3
382382
# Check yard lights relay is on
383383
yard_relay = [r for r in telem.relay if r.system_id == 27][0]
384384
assert yard_relay.state.value == 1 # ON

0 commit comments

Comments
 (0)