Skip to content

Commit 0d94ae7

Browse files
committed
feat: move to pydantic v2 and improve CLI get light output
1 parent 9db600e commit 0d94ae7

11 files changed

Lines changed: 293 additions & 143 deletions

File tree

poetry.lock

Lines changed: 169 additions & 57 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyomnilogic_local/cli/get/commands.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators
22
# mypy: disable-error-code="misc"
33

4+
from typing import Any
5+
46
import click
57

68
from pyomnilogic_local.cli import ensure_connection
9+
from pyomnilogic_local.models.mspconfig import (
10+
MSPColorLogicLight,
11+
MSPConfig,
12+
)
13+
from pyomnilogic_local.models.telemetry import (
14+
Telemetry,
15+
TelemetryType,
16+
)
17+
from pyomnilogic_local.omnitypes import (
18+
ColorLogicBrightness,
19+
ColorLogicPowerState,
20+
ColorLogicShow,
21+
ColorLogicSpeed,
22+
)
723

824

925
@click.group()
@@ -30,51 +46,56 @@ def lights(ctx: click.Context) -> None:
3046
Example:
3147
omnilogic get lights
3248
"""
33-
mspconfig = ctx.obj["MSPCONFIG"]
49+
mspconfig: MSPConfig = ctx.obj["MSPCONFIG"]
50+
telemetry: Telemetry = ctx.obj["TELEMETRY"]
3451

3552
lights_found = False
3653

3754
# Check for lights in the backyard
3855
if mspconfig.backyard.colorlogic_light:
3956
for light in mspconfig.backyard.colorlogic_light:
4057
lights_found = True
41-
_print_light_info(light)
58+
_print_light_info(light, telemetry.get_telem_by_systemid(light.system_id))
4259

4360
# Check for lights in Bodies of Water
4461
if mspconfig.backyard.bow:
4562
for bow in mspconfig.backyard.bow:
4663
if bow.colorlogic_light:
4764
for cl_light in bow.colorlogic_light:
4865
lights_found = True
49-
_print_light_info(cl_light)
66+
_print_light_info(cl_light, telemetry.get_telem_by_systemid(cl_light.system_id))
5067

5168
if not lights_found:
5269
click.echo("No ColorLogic lights found in the system configuration.")
5370

5471

55-
def _print_light_info(light: object) -> None:
72+
def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryType | None) -> None:
5673
"""Format and print light information in a nice table format.
5774
5875
Args:
5976
light: Light object from MSPConfig with attributes to display
77+
telemetry: Telemetry object containing current state information
6078
"""
6179
click.echo("\n" + "=" * 60)
62-
for attr_name in dir(light):
63-
# Skip private/magic attributes and methods
64-
if attr_name.startswith("_") or callable(getattr(light, attr_name)):
65-
continue
66-
67-
value = getattr(light, attr_name)
6880

69-
# Special handling for show lists - convert to readable format
70-
if attr_name == "current_show" and isinstance(value, list):
71-
show_names = [show.name if hasattr(show, "name") else str(show) for show in value]
81+
light_data: dict[Any, Any] = {**dict(light), **dict(telemetry)} if telemetry else dict(light)
82+
for attr_name, value in light_data.items():
83+
if attr_name == "brightness":
84+
value = ColorLogicBrightness(value).pretty()
85+
elif attr_name == "effects" and isinstance(value, list):
86+
show_names = [show.pretty() if hasattr(show, "pretty") else str(show) for show in value]
7287
value = ", ".join(show_names) if show_names else "None"
88+
elif attr_name == "show" and value is not None:
89+
value = ColorLogicShow(value).pretty()
90+
elif attr_name == "speed":
91+
value = ColorLogicSpeed(value).pretty()
92+
elif attr_name == "state":
93+
value = ColorLogicPowerState(value).pretty()
7394
elif isinstance(value, list):
7495
# Format other lists nicely
7596
value = ", ".join(str(v) for v in value) if value else "None"
7697

77-
# Format the attribute name to be more readable
98+
# # Format the attribute name to be more readable
7899
display_name = attr_name.replace("_", " ").title()
79100
click.echo(f"{display_name:20} : {value}")
80101
click.echo("=" * 60)

pyomnilogic_local/cli/utils.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
"""
66

77
import asyncio
8-
from typing import Any, Literal, overload
8+
from typing import Literal, overload
99

1010
import click
1111

1212
from pyomnilogic_local.api import OmniLogicAPI
1313
from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics
14+
from pyomnilogic_local.models.mspconfig import MSPConfig
15+
from pyomnilogic_local.models.telemetry import Telemetry
1416

1517

1618
async def get_omni(host: str) -> OmniLogicAPI:
@@ -25,7 +27,7 @@ async def get_omni(host: str) -> OmniLogicAPI:
2527
return OmniLogicAPI(host, 10444, 5.0)
2628

2729

28-
async def fetch_startup_data(omni: OmniLogicAPI) -> tuple[Any, Any]:
30+
async def fetch_startup_data(omni: OmniLogicAPI) -> tuple[MSPConfig, Telemetry]:
2931
"""Fetch MSPConfig and Telemetry from the controller.
3032
3133
Args:

pyomnilogic_local/models/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
"""Constants for the models package."""
2+
13
XML_NS = {"api": "http://nextgen.hayward.com/api"}

pyomnilogic_local/models/filter_diagnostics.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,30 @@
11
from __future__ import annotations
22

3-
from pydantic.v1 import BaseModel, Field
3+
from pydantic import BaseModel, ConfigDict, Field
44
from xmltodict import parse as xml_parse
55

66

77
class FilterDiagnosticsParameter(BaseModel):
8+
model_config = ConfigDict(populate_by_name=True)
9+
810
name: str = Field(alias="@name")
911
dataType: str = Field(alias="@dataType")
1012
value: int = Field(alias="#text")
1113

1214

1315
class FilterDiagnosticsParameters(BaseModel):
16+
model_config = ConfigDict(populate_by_name=True)
17+
1418
parameter: list[FilterDiagnosticsParameter] = Field(alias="Parameter")
1519

1620

1721
class FilterDiagnostics(BaseModel):
22+
model_config = ConfigDict(populate_by_name=True)
23+
1824
name: str = Field(alias="Name")
1925
# parameters: FilterDiagnosticsParameters = Field(alias="Parameters")
2026
parameters: list[FilterDiagnosticsParameter] = Field(alias="Parameters")
2127

22-
class Config:
23-
orm_mode = True
24-
2528
def get_param_by_name(self, name: str) -> int:
2629
return [param.value for param in self.parameters if param.name == name][0]
2730

@@ -36,4 +39,4 @@ def load_xml(xml: str) -> FilterDiagnostics:
3639
# The XML nests the Parameter entries under a Parameters entry, this is annoying to work with. Here we are adjusting the data to
3740
# remove that extra level in the data
3841
data["Response"]["Parameters"] = data["Response"]["Parameters"]["Parameter"]
39-
return FilterDiagnostics.parse_obj(data["Response"])
42+
return FilterDiagnostics.model_validate(data["Response"])
Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
11
from __future__ import annotations
22

3-
from pydantic.v1 import BaseModel, Field
3+
from typing import Any
4+
from xml.etree.ElementTree import Element
45

5-
from .util import ParameterGetter
6+
from pydantic import BaseModel, ConfigDict, Field, model_validator
7+
8+
from .const import XML_NS
69

710

811
class LeadMessage(BaseModel):
12+
model_config = ConfigDict(populate_by_name=True)
13+
914
source_op_id: int = Field(alias="SourceOpId")
1015
msg_size: int = Field(alias="MsgSize")
1116
msg_block_count: int = Field(alias="MsgBlockCount")
1217
type: int = Field(alias="Type")
1318

14-
class Config:
15-
orm_mode = True
16-
getter_dict = ParameterGetter
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

pyomnilogic_local/models/mspconfig.py

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
else:
1010
from typing_extensions import Self
1111

12-
from pydantic.v1 import BaseModel, Field, ValidationError
12+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
1313
from xmltodict import parse as xml_parse
1414

1515
from ..exceptions import OmniParsingException
@@ -37,11 +37,16 @@
3737
class OmniBase(BaseModel):
3838
_sub_devices: set[str] | None = None
3939
system_id: int = Field(alias="System-Id")
40-
name: str | None = Field(alias="Name")
41-
bow_id: int | None
40+
name: str | None = Field(alias="Name", default=None)
41+
bow_id: int | None = None
4242

4343
def without_subdevices(self) -> Self:
44-
return self.copy(exclude=self._sub_devices)
44+
data = self.model_dump(exclude=self._sub_devices, round_trip=True)
45+
data = {**data, **{}}
46+
copied = self.model_validate(data)
47+
_LOGGER.debug("without_subdevices: original=%s, copied=%s", self, copied)
48+
return copied
49+
# return self.copy(exclude=self._sub_devices)
4550

4651
def propagate_bow_id(self, bow_id: int | None) -> None:
4752
# First we set our own bow_id
@@ -63,6 +68,8 @@ def propagate_bow_id(self, bow_id: int | None) -> None:
6368

6469

6570
class MSPSystem(BaseModel):
71+
model_config = ConfigDict(from_attributes=True)
72+
6673
omni_type: OmniType = OmniType.SYSTEM
6774
vsp_speed_format: Literal["RPM", "Percent"] = Field(alias="Msp-Vsp-Speed-Format")
6875
units: Literal["Standard", "Metric"] = Field(alias="Units")
@@ -116,7 +123,7 @@ class MSPHeaterEquip(OmniBase):
116123
enabled: Literal["yes", "no"] = Field(alias="Enabled")
117124
min_filter_speed: int = Field(alias="Min-Speed-For-Operation")
118125
sensor_id: int = Field(alias="Sensor-System-Id")
119-
supports_cooling: Literal["yes", "no"] | None = Field(alias="SupportsCooling")
126+
supports_cooling: Literal["yes", "no"] | None = Field(alias="SupportsCooling", default=None)
120127

121128

122129
# This is the entry for the VirtualHeater, it does not use OmniBase because it has no name attribute
@@ -126,18 +133,18 @@ class MSPVirtualHeater(OmniBase):
126133
omni_type: OmniType = OmniType.VIRT_HEATER
127134
enabled: Literal["yes", "no"] = Field(alias="Enabled")
128135
set_point: int = Field(alias="Current-Set-Point")
129-
solar_set_point: int | None = Field(alias="SolarSetPoint")
136+
solar_set_point: int | None = Field(alias="SolarSetPoint", default=None)
130137
max_temp: int = Field(alias="Max-Settable-Water-Temp")
131138
min_temp: int = Field(alias="Min-Settable-Water-Temp")
132-
heater_equipment: list[MSPHeaterEquip] | None
139+
heater_equipment: list[MSPHeaterEquip] | None = None
133140

134141
def __init__(self, **data: Any) -> None:
135142
super().__init__(**data)
136143

137144
# The heater equipment are nested down inside a list of "Operations", which also includes non Heater-Equipment items. We need to
138145
# first filter down to just the heater equipment items, then populate our self.heater_equipment with parsed versions of those items.
139146
heater_equip_data = [op[OmniType.HEATER_EQUIP] for op in data.get("Operation", {}) if OmniType.HEATER_EQUIP in op]
140-
self.heater_equipment = [MSPHeaterEquip.parse_obj(equip) for equip in heater_equip_data]
147+
self.heater_equipment = [MSPHeaterEquip.model_validate(equip) for equip in heater_equip_data]
141148

142149

143150
class MSPChlorinatorEquip(OmniBase):
@@ -163,7 +170,9 @@ def __init__(self, **data: Any) -> None:
163170
# The heater equipment are nested down inside a list of "Operations", which also includes non Heater-Equipment items. We need to
164171
# first filter down to just the heater equipment items, then populate our self.heater_equipment with parsed versions of those items.
165172
chlorinator_equip_data = [op for op in data.get("Operation", {}) if OmniType.CHLORINATOR_EQUIP in op][0]
166-
self.chlorinator_equipment = [MSPChlorinatorEquip.parse_obj(equip) for equip in chlorinator_equip_data[OmniType.CHLORINATOR_EQUIP]]
173+
self.chlorinator_equipment = [
174+
MSPChlorinatorEquip.model_validate(equip) for equip in chlorinator_equip_data[OmniType.CHLORINATOR_EQUIP]
175+
]
167176

168177

169178
class MSPCSAD(OmniBase):
@@ -185,8 +194,8 @@ class MSPCSAD(OmniBase):
185194
class MSPColorLogicLight(OmniBase):
186195
omni_type: OmniType = OmniType.CL_LIGHT
187196
type: ColorLogicLightType | str = Field(alias="Type")
188-
v2_active: Literal["yes", "no"] | None = Field(alias="V2-Active")
189-
effects: list[ColorLogicShow] | None
197+
v2_active: Literal["yes", "no"] | None = Field(alias="V2-Active", default=None)
198+
effects: list[ColorLogicShow] | None = None
190199

191200
def __init__(self, **data: Any) -> None:
192201
super().__init__(**data)
@@ -199,14 +208,14 @@ class MSPBoW(OmniBase):
199208
omni_type: OmniType = OmniType.BOW
200209
type: BodyOfWaterType | str = Field(alias="Type")
201210
supports_spillover: Literal["yes", "no"] = Field(alias="Supports-Spillover")
202-
filter: list[MSPFilter] | None = Field(alias="Filter")
203-
relay: list[MSPRelay] | None = Field(alias="Relay")
204-
heater: MSPVirtualHeater | None = Field(alias="Heater")
205-
sensor: list[MSPSensor] | None = Field(alias="Sensor")
206-
colorlogic_light: list[MSPColorLogicLight] | None = Field(alias="ColorLogic-Light")
207-
pump: list[MSPPump] | None = Field(alias="Pump")
208-
chlorinator: MSPChlorinator | None = Field(alias="Chlorinator")
209-
csad: list[MSPCSAD] | None = Field(alias="CSAD")
211+
filter: list[MSPFilter] | None = Field(alias="Filter", default=None)
212+
relay: list[MSPRelay] | None = Field(alias="Relay", default=None)
213+
heater: MSPVirtualHeater | None = Field(alias="Heater", default=None)
214+
sensor: list[MSPSensor] | None = Field(alias="Sensor", default=None)
215+
colorlogic_light: list[MSPColorLogicLight] | None = Field(alias="ColorLogic-Light", default=None)
216+
pump: list[MSPPump] | None = Field(alias="Pump", default=None)
217+
chlorinator: MSPChlorinator | None = Field(alias="Chlorinator", default=None)
218+
csad: list[MSPCSAD] | None = Field(alias="CSAD", default=None)
210219

211220
# 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
212221
# itself is initialized
@@ -219,16 +228,16 @@ class MSPBackyard(OmniBase):
219228
_sub_devices = {"sensor", "bow", "colorlogic_light", "relay"}
220229

221230
omni_type: OmniType = OmniType.BACKYARD
222-
sensor: list[MSPSensor] | None = Field(alias="Sensor")
223-
bow: list[MSPBoW] | None = Field(alias="Body-of-water")
224-
relay: list[MSPRelay] | None = Field(alias="Relay")
225-
colorlogic_light: list[MSPColorLogicLight] | None = Field(alias="ColorLogic-Light")
231+
sensor: list[MSPSensor] | None = Field(alias="Sensor", default=None)
232+
bow: list[MSPBoW] | None = Field(alias="Body-of-water", default=None)
233+
relay: list[MSPRelay] | None = Field(alias="Relay", default=None)
234+
colorlogic_light: list[MSPColorLogicLight] | None = Field(alias="ColorLogic-Light", default=None)
226235

227236

228237
class MSPSchedule(OmniBase):
229238
omni_type: OmniType = OmniType.SCHEDULE
230239
system_id: int = Field(alias="schedule-system-id")
231-
bow_id: int = Field(alias="bow-system-id")
240+
bow_id: int | None = Field(alias="bow-system-id", default=None)
232241
equipment_id: int = Field(alias="equipment-id")
233242
enabled: bool = Field()
234243

@@ -239,12 +248,11 @@ class MSPSchedule(OmniBase):
239248

240249

241250
class MSPConfig(BaseModel):
251+
model_config = ConfigDict(from_attributes=True)
252+
242253
system: MSPSystem = Field(alias="System")
243254
backyard: MSPBackyard = Field(alias="Backyard")
244255

245-
class Config:
246-
orm_mode = True
247-
248256
@staticmethod
249257
def load_xml(xml: str) -> MSPConfig:
250258
data = xml_parse(
@@ -266,6 +274,6 @@ def load_xml(xml: str) -> MSPConfig:
266274
),
267275
)
268276
try:
269-
return MSPConfig.parse_obj(data["MSPConfig"])
277+
return MSPConfig.model_validate(data["MSPConfig"], from_attributes=True)
270278
except ValidationError as exc:
271279
raise OmniParsingException(f"Failed to parse MSP Configuration: {exc}") from exc

0 commit comments

Comments
 (0)